@cubicecho/agent-core 2.11.0 → 2.12.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 CHANGED
@@ -27,9 +27,11 @@ only, Node >=22.
27
27
  | `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
28
28
  | `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. `askJson` holds the answer to a schema where the server can. |
29
29
  | `hooks` | The host's side of lifecycle hooks: `gather` before a request and `notify` after, the shared context budget, `withContext` to put what they add on the turn's question, `untrusted` to fence text nobody vouched for, and `turnMessages` to hand them a transcript. Running a hook is a runner the caller passes. |
30
- | `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`. A watcher's backlog is capped and reports its own gaps. |
30
+ | `events` | The in-memory bus a watcher reads while a run happens: `emit`, `watch`, `history`, `fold`, and `runMetrics` for what a run cost. A watcher's backlog is capped and reports its own gaps. |
31
31
  | `client` | A pooled `OpenAI` client per endpoint, plus the context window: the served one where a local server says, the listed one otherwise, and their caches. |
32
32
  | `retry` | What to do when a request is lost, refused or too big: `isTransient`, `isModelLoading`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
33
+ | `calibration` | How many characters a token is worth on one model, learned from the prompt counts its endpoint reports: `charsPerTokenFor`, `calibrate`. |
34
+ | `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
33
35
  | `config` | The structural interfaces every function here asks for. |
34
36
  | `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
35
37
  | `agent-loop` | `runAgentLoop`: the loop above a turn — `runTurn` per step, the tools between, `load_tools` and preselection handled, until the model stops asking. Plus the parts it is made of: `buildBody`, `preselect`, `preview`, and `resolveApiKey` for a caller deciding which key an endpoint gets. |
@@ -87,6 +89,20 @@ truncated prose or — the case that bites — a tool call whose `arguments` sto
87
89
  caller meets a parse failure with nothing to attribute it to. `finishReason` is `"length"` there,
88
90
  `""` where the endpoint never said.
89
91
 
92
+ `usage` is `prompt`, `completion`, `total` and `cached`, always there and zero where the server
93
+ sent nothing. Everything else on it is there only when something measured it, and absent rather
94
+ than zero otherwise:
95
+
96
+ | Field | From |
97
+ | --- | --- |
98
+ | `uncached` | the prompt less `cached`, only where a cache count was reported, so a cold cache and no report read differently |
99
+ | `reasoningTokens` | `completion_tokens_details.reasoning_tokens`; never estimated from the thinking stream |
100
+ | `promptMs`, `promptTokensPerSecond`, `predictedMs`, `tokensPerSecond`, `draftTotal`, `draftAccepted` | llama.cpp's `timings`, which also stands in for `cached` (`cache_n`) where the usage has no cache count |
101
+ | `firstTokenMs` | `streamTurn`, from the request to the first chunk that carried something |
102
+ | `wallMs`, `retries`, `timeouts` | `runTurn`: the whole turn with its backoff, the lost requests sent again, and how many of those went silent |
103
+ | `continuations` | `continueTurn` |
104
+ | `cacheExpected`, `cacheBroken`, `cacheBreakReason`, `toolsDeclared`, `toolSchemaTokens` | `runAgentLoop`, below |
105
+
90
106
  `reasoning` is the scratchpad `onThinking` was told, kept because two common families want it
91
107
  back. gpt-oss and DeepSeek in thinking mode read the analysis behind a tool call off the assistant
92
108
  message on the next request: store it as `reasoning_content` on that message while it ends in a
@@ -150,6 +166,10 @@ and the newer spelling is exactly the one an older model or a llama.cpp-shaped e
150
166
  `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true`, and an absent model has to read
151
167
  the same way it does.
152
168
 
169
+ One more model flag is never answered by `negotiate`: `assistantPrefill`, whether the model
170
+ carries on a trailing assistant message rather than answering afresh after it. Only `continueTurn`
171
+ sends one, so only it latches the flag, and it is persisted in a snapshot like the rest.
172
+
153
173
  `runTurn` takes the same option and hands `request` the same second argument. Keying on
154
174
  `(endpoint, model)` rather than the model name alone is the part worth keeping: `gpt-4o` at
155
175
  OpenAI and `gpt-4o` behind a proxy need not be the same weights, and one that refused a reasoning
@@ -251,6 +271,30 @@ nothing, and the server gives the reply whatever the prompt leaves. A consumer t
251
271
  `maxTokens` from the limit itself before calling in no longer needs to, and doing both reserves
252
272
  the ceiling twice.
253
273
 
274
+ How many tokens the body is, is a guess: characters over a divisor. Four is right for English prose
275
+ and wrong for what a tool-using run is made of — JSON schemas and tool results pack closer to two or
276
+ three characters a token — so a request the guard let through was refused anyway. The divisor is
277
+ learned instead. Every turn `runTurn` answers comes back with the endpoint's exact prompt count for a
278
+ body whose characters were already counted, and `calibrate` keeps that ratio per endpoint and model;
279
+ the next request to the same model is sized by `charsPerTokenFor`, which is `CHARS_PER_TOKEN` (4)
280
+ until a turn has reported one. It is the highest of the model's last four readings, which is the
281
+ lowest token count: a count that comes out high refuses a run that would have fit, and one that
282
+ comes out low only costs the round trip the guard was saving. A reading from a request carrying an
283
+ image, or outside one to eight characters a token, is not taken.
284
+
285
+ `requestTokens` and `messageTokens` take `{ charsPerToken }`, and so does `planCompaction`, so a
286
+ caller sizing its own work can use the same number:
287
+
288
+ ```ts
289
+ const charsPerToken = charsPerTokenFor(capabilitiesFor(config.baseUrl, config.apiKey), config.model);
290
+ const plan = planCompaction(messages, { limit, used, charsPerToken });
291
+ ```
292
+
293
+ The ratio is a measurement rather than a refusal, so it is not a capability latch and is not in
294
+ `exportCapabilities`: it moves every turn, and a restarted process learns it again from its first
295
+ one. llama.cpp's `/tokenize` would count exactly, but only after `/apply-template` renders the
296
+ prompt — two round trips before every guarded request, on one server — so it is not used.
297
+
254
298
  The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
255
299
  before it and the transcript does not change between retries. A `ContextOverflow` from this is
256
300
  neither a capability `negotiate` can answer nor something `isTransient` accepts, so it leaves
@@ -331,6 +375,55 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
331
375
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
332
376
  and everything is back from the second step on.
333
377
 
378
+ A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
379
+ continued first. `continueTurn` is the same thing for a caller with its own loop:
380
+
381
+ ```ts
382
+ let turn = await runTurn(client, supports, build, options);
383
+ turn = await continueTurn(client, supports, build, turn, { ...options, maxContinuations: 2 });
384
+ ```
385
+
386
+ It sends the transcript again with the answer so far as a trailing assistant message, which
387
+ llama.cpp renders as a prefill: the model carries on from the last token, and the cache holds all
388
+ of the prompt and most of the reply. vLLM only does so given `continue_final_message: true` and
389
+ `add_generation_prompt: false` on that request, which nothing here sends yet. The pieces come
390
+ back as one turn — content and reasoning joined, token counts summed, `continuations` counting the
391
+ extra requests, timings summed or weighted, a field only one piece reported dropped. Only an answer
392
+ begun and cut off is continued. A turn cut off in its scratchpad is left alone, since llama.cpp
393
+ refuses a prefill outright on a template with thinking on, and so is one ending in a tool call,
394
+ whose truncated arguments `parseToolArguments` already reports.
395
+
396
+ Whether it works is latched per model as `assistantPrefill`. A 400 or 422 for the request latches it
397
+ off, and so does a continuation that starts the answer over word for word — how hosted OpenAI, which
398
+ takes a trailing assistant message and ignores it, shows itself. Either way, and on any other
399
+ failure short of a stop, the cut-off answer is kept with a notice. The cap is one continuation for
400
+ `continueTurn` and none for the loop, since it spends a request, and on a server that does not
401
+ continue, one to find out.
402
+
403
+ Every turn ends in a `usage` event, whether or not the endpoint reported tokens. Its totals are the
404
+ run's so far; its `turn` is that turn's own `TurnUsage` and `finishReason`. The loop adds what only
405
+ it can know: `toolsDeclared` and `toolSchemaTokens` for the tool block it sent, and, from the second
406
+ request on, `cacheExpected` (the previous prompt plus its reply) and — where a cache count was
407
+ reported — `cacheBroken`, a hit short of 90% of the previous prompt. A broken cache is given a
408
+ `cacheBreakReason` read off the request against the one before: `tools-changed`, `system-changed`,
409
+ `history-rewritten` (a compaction or a prune in `beforeStep`), or `none-known` where the new request
410
+ only appended, which points at the server — a slot evicted, a template that re-renders the tail.
411
+
412
+ `runMetrics(events)` adds a run up from those events — tokens, cache hit ratio and breaks by
413
+ reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
414
+ acceptance, the largest prompt against a `contextLength`, turns cut off, tool errors by name, and
415
+ an `outcome`. The loop returns it as `metrics`, with the `load_tools` counts only it can see
416
+ (`toolsLoaded`, `redundantLoads`, `unknownToolNames`). It is a sibling of `fold` rather than part
417
+ of it, since `fold`'s blocks are for display and a summary is not one:
418
+
419
+ ```ts
420
+ const metrics = runMetrics(history(runId), { contextLength: config.contextLength });
421
+ ```
422
+
423
+ Counts are always present; every other field is absent where no turn reported what it is made of.
424
+ What it cannot say: whether a failed run was stopped, errored or ran out of tool iterations, and
425
+ whether the host compacted — neither is in the events.
426
+
334
427
  `beforeStep` is handed the transcript before each request and may return a replacement, which is
335
428
  where compaction goes (below). Hooks are gathered once, onto the question, and never written into
336
429
  the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
@@ -379,7 +472,7 @@ once `used` (the last turn's prompt tokens, or the estimate) is past three quart
379
472
  The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
380
473
  mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
381
474
  summary is continued rather than summarised. `compactTranscript` writes the summary and tells
382
- `beforeCompact` hooks what is going while it does. A hook cannot veto it. Because the cut lands on
475
+ `beforeCompact` hooks what is going while it does. Because the cut lands on
383
476
  a user message, one long tool run under a single question has nothing to fold — pruning is what
384
477
  keeps that one going.
385
478
 
@@ -396,6 +489,21 @@ beforeStep: async (messages, step) => {
396
489
  },
397
490
  ```
398
491
 
492
+ A hook can ask for a compaction not to happen — its runner sets `veto` on the outcome — and by default
493
+ nobody listens: the hooks run beside the summary, so a slow one costs the run nothing. Pass
494
+ `honourVeto: true` with the hooks and they run first — the summary waits on them — and a veto from
495
+ any hook that ran leaves the transcript as it was, with a note naming the hook. One that failed
496
+ vetoes nothing. A compaction passed `forced: true`, because a request was already refused as too
497
+ big, goes ahead regardless: a veto there only trades the summary for a `ContextOverflow`.
498
+ `consult` is the same wait for a host that compacts its own way.
499
+
500
+ ```ts
501
+ const compacted = await compactTranscript(messages, plan, summarise, {
502
+ hooks: { run, context, onNote, honourVeto: true },
503
+ forced: retryingAfterOverflow, // the last request came back as a ContextOverflow
504
+ });
505
+ ```
506
+
399
507
  **Both rewrite the prefix.** A prompt cache matches from the first token, so a transcript whose
400
508
  early messages change is re-processed whole — on a local server that is the entire prefill, every
401
509
  time. Run them rarely and together, at the point `planCompaction` says the window is filling, so
@@ -579,7 +687,8 @@ with no timeout to give now leaves it out rather than inventing a `0`.
579
687
  ## What is kept for the life of the process
580
688
 
581
689
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
582
- capabilities, and `side-task`'s no-thinking hints. All four are module-level and keyed on the same
690
+ capabilities, and `side-task`'s no-thinking hints. A fifth, the characters per token each model was
691
+ measured at, is keyed on the endpoint's capabilities object, so it goes with them. All four are module-level and keyed on the same
583
692
  notion of an endpoint — its base URL and its API key — and the clients' key carries the request
584
693
  timeout as well, since that changes how a request is sent.
585
694
 
@@ -619,7 +728,8 @@ URL and the key, an absent key read as `NO_KEY` — rather than rebuilding that
619
728
  cannot drift. `endpointKey` holds the key in the clear; `endpointId`, its SHA-256 digest, is the one
620
729
  that is safe to write down.
621
730
 
622
- `resetAll` drops all four, and `reset.ts` names each seam separately for a test that wants one.
731
+ `resetAll` drops all five, and `reset.ts` names each seam separately for a test that wants one
732
+ `resetCalibration` for the measured ratios.
623
733
 
624
734
  The latches can outlive the process as well, because otherwise every restart spends one refused
625
735
  request per endpoint and model learning the same facts again. `exportCapabilities` returns every
@@ -2,7 +2,7 @@ import type OpenAI from "openai";
2
2
  import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
3
  import type { CatalogServer } from "./catalog.ts";
4
4
  import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts";
5
- import type { RunEventInput } from "./events.ts";
5
+ import { type RunEventInput, type RunMetrics } from "./events.ts";
6
6
  import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
7
7
  import type { Turn, TurnUsage } from "./stream.ts";
8
8
  /**
@@ -168,6 +168,12 @@ export interface AgentLoopOptions {
168
168
  recoverToolCalls?: boolean;
169
169
  /** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
170
170
  onTurn?: (turn: Turn, step: number) => void;
171
+ /**
172
+ * How many times an answer cut off at `maxTokens` is continued, zero — the default — for never.
173
+ * Opt-in because it spends another request, and on a server that does not continue a trailing
174
+ * assistant message it spends one to find that out. See `continueTurn`.
175
+ */
176
+ maxContinuations?: number;
171
177
  }
172
178
  /** What a finished loop hands back. */
173
179
  export interface AgentLoopResult {
@@ -185,6 +191,11 @@ export interface AgentLoopResult {
185
191
  used: string[];
186
192
  /** The hooks' notes from before the first request. */
187
193
  notes: HookNote[];
194
+ /**
195
+ * The run summed and derived: what `runMetrics` makes of the events this loop emitted, plus the
196
+ * `load_tools` findings only the loop sees, `wallMs` from the call to the return, and `outcome`.
197
+ */
198
+ metrics: RunMetrics;
188
199
  }
189
200
  /**
190
201
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
@@ -195,7 +206,9 @@ export interface AgentLoopResult {
195
206
  * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
196
207
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
197
208
  * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
198
- * because it otherwise reads exactly like a finished one.
209
+ * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
210
+ * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
211
+ * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
199
212
  *
200
213
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
201
214
  * and cancellation. See `AgentLoopOptions`.
@@ -1,7 +1,11 @@
1
+ import { charsPerTokenFor } from "./calibration.js";
1
2
  import { capabilitiesFor } from "./capabilities.js";
2
3
  import { firstTokenMs, getClient, NO_KEY, timeoutMs } from "./client.js";
4
+ import { continueTurn } from "./continuation.js";
3
5
  import { errorMessage } from "./errors.js";
6
+ import { runMetrics } from "./events.js";
4
7
  import { gather, notify, turnIndex, turnMessages, withContext, } from "./hooks.js";
8
+ import { toolsChars } from "./retry.js";
5
9
  import { runTurn } from "./run-turn.js";
6
10
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
7
11
  import { askJson, tryAsk } from "./side-task.js";
@@ -119,11 +123,60 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
119
123
  const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
120
124
  return preselection(reply, catalog, maxPerLoad);
121
125
  }
122
- /** Every numeric field of one usage added into another. */
126
+ /**
127
+ * The four token counts of one usage added into another. Only those: the rest are measurements a
128
+ * sum of would mean nothing, or would mean something only `runMetrics` knows how to weigh.
129
+ */
123
130
  const accumulate = (total, turn) => {
124
- for (const key of Object.keys(turn))
125
- total[key] += turn[key] ?? 0;
131
+ total.prompt += turn.prompt;
132
+ total.completion += turn.completion;
133
+ total.total += turn.total;
134
+ total.cached += turn.cached;
135
+ };
136
+ /** Whether two messages say the same thing, by identity first since most of a transcript is. */
137
+ const sameMessage = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
138
+ /** The system messages a request opens with, which a template renders ahead of the history. */
139
+ const leadingSystem = (messages) => {
140
+ const end = messages.findIndex((message) => message.role !== "system");
141
+ return messages.slice(0, end === -1 ? messages.length : end);
126
142
  };
143
+ /**
144
+ * Where a request stopped matching the one before it, earliest in the rendered prompt first — the
145
+ * tool block, then the system prompt, then the history — or `none-known` where it only appended.
146
+ */
147
+ function breakReason(previous, messages, tools) {
148
+ if (previous.tools.length !== tools.length ||
149
+ previous.tools.some((name, at) => name !== tools[at]))
150
+ return "tools-changed";
151
+ const before = leadingSystem(previous.messages);
152
+ const now = leadingSystem(messages);
153
+ if (before.length !== now.length || before.some((message, at) => !sameMessage(message, now[at])))
154
+ return "system-changed";
155
+ if (previous.messages.length > messages.length ||
156
+ previous.messages.some((message, at) => !sameMessage(message, messages[at])))
157
+ return "history-rewritten";
158
+ return "none-known";
159
+ }
160
+ /**
161
+ * The share of the previous prompt a cache that kept its prefix reports as hit. Short of it by
162
+ * more than this is a break, not the few tokens a template re-renders at the join.
163
+ */
164
+ const CACHE_KEPT = 0.9;
165
+ /**
166
+ * What one turn's cache should have been and whether it was, against the request before it.
167
+ * Nothing before the second request, or where the previous prompt was not reported.
168
+ */
169
+ function cacheDiagnosis(previous, messages, tools, usage) {
170
+ if (!previous || !(previous.prompt > 0))
171
+ return {};
172
+ const found = { cacheExpected: previous.prompt + previous.completion };
173
+ if (usage.uncached === undefined)
174
+ return found;
175
+ found.cacheBroken = usage.cached < previous.prompt * CACHE_KEPT;
176
+ if (found.cacheBroken)
177
+ found.cacheBreakReason = breakReason(previous, messages, tools);
178
+ return found;
179
+ }
127
180
  /**
128
181
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
129
182
  * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
@@ -133,18 +186,43 @@ const accumulate = (total, turn) => {
133
186
  * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
134
187
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
135
188
  * preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
136
- * because it otherwise reads exactly like a finished one.
189
+ * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
190
+ * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
191
+ * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
137
192
  *
138
193
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
139
194
  * and cancellation. See `AgentLoopOptions`.
140
195
  */
141
196
  export async function runAgentLoop(options) {
142
197
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
143
- const { onEvent, onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, } = options;
198
+ const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, } = options;
199
+ const started = Date.now();
200
+ // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
201
+ // than by the bus, which the loop does not know about.
202
+ const recorded = [];
203
+ const record = (input) => {
204
+ if (input.kind === "thinking" || input.kind === "output")
205
+ return;
206
+ recorded.push({
207
+ runId: "",
208
+ seq: recorded.length + 1,
209
+ at: Date.now(),
210
+ text: "",
211
+ name: "",
212
+ step: "",
213
+ ok: null,
214
+ usage: null,
215
+ ...input,
216
+ });
217
+ };
218
+ const onEvent = (input) => {
219
+ record(input);
220
+ options.onEvent?.(input);
221
+ };
144
222
  const client = getClient(config);
145
223
  const supports = capabilitiesFor(config.baseUrl, config.apiKey);
146
224
  const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
147
- const notice = (text) => onEvent?.({ kind: "notice", text });
225
+ const notice = (text) => onEvent({ kind: "notice", text });
148
226
  const onDemand = config.toolDiscovery === "ondemand" && catalog.length > 0;
149
227
  const loaded = new Set(onDemand ? (options.loaded ?? []) : []);
150
228
  const preselected = onDemand ? [...(options.preselected ?? [])] : [];
@@ -175,12 +253,14 @@ export async function runAgentLoop(options) {
175
253
  const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
176
254
  const toolCalls = [];
177
255
  const answered = new Map();
256
+ const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
257
+ let previous;
178
258
  for (let step = 0; step < config.maxToolIterations; step++) {
179
259
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
180
260
  // end — so the signal is read between steps as well.
181
261
  signal?.throwIfAborted();
182
262
  messages = (await beforeStep?.(messages, step)) ?? messages;
183
- onEvent?.({ kind: "turn", text: `turn ${step + 1}` });
263
+ onEvent({ kind: "turn", text: `turn ${step + 1}` });
184
264
  const routed = preselected.length > 0 && step === 0;
185
265
  const declared = routed
186
266
  ? byName(new Set(preselected))
@@ -195,7 +275,8 @@ export async function runAgentLoop(options) {
195
275
  ...(prompt ? [{ role: "system", content: prompt }] : []),
196
276
  ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
197
277
  ];
198
- const turn = await runTurn(client, supports, (supported, refused) => buildBody(config, supported, refused, request, declared), {
278
+ const build = (supported, refused) => buildBody(config, supported, refused, request, declared);
279
+ const turnOptions = {
199
280
  model: config.model,
200
281
  droppable: Object.keys(config.extraBody ?? {}),
201
282
  maxRetries,
@@ -207,20 +288,38 @@ export async function runAgentLoop(options) {
207
288
  idleMs: timeoutMs(config),
208
289
  firstChunkMs: firstTokenMs(config) ?? 0,
209
290
  onNotice: notice,
210
- onThinking: (text) => onEvent?.({ kind: "thinking", text }),
211
- onOutput: (text) => onEvent?.({ kind: "output", text }),
291
+ onThinking: (text) => onEvent({ kind: "thinking", text }),
292
+ onOutput: (text) => onEvent({ kind: "output", text }),
293
+ };
294
+ const first = await runTurn(client, supports, build, turnOptions);
295
+ const names = declared.map((tool) => (tool.type === "function" ? tool.function.name : ""));
296
+ // Compared before any continuation is joined on: the cache a request meets is the one its own
297
+ // prompt found, and a continuation's prompt is this request's plus the reply so far.
298
+ Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
299
+ toolsDeclared: declared.length,
300
+ toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerTokenFor(supports, config.model)),
212
301
  });
302
+ const firstPrompt = first.usage.prompt;
303
+ const turn = maxContinuations > 0
304
+ ? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
305
+ : first;
306
+ previous = {
307
+ messages: request,
308
+ tools: names,
309
+ prompt: firstPrompt,
310
+ completion: turn.usage.completion,
311
+ };
213
312
  accumulate(usage, turn.usage);
214
- if (turn.usage.total > 0 || turn.usage.prompt > 0 || turn.usage.completion > 0) {
215
- onEvent?.({
216
- kind: "usage",
217
- usage: {
218
- promptTokens: usage.prompt,
219
- completionTokens: usage.completion,
220
- totalTokens: usage.total,
221
- },
222
- });
223
- }
313
+ onEvent({
314
+ kind: "usage",
315
+ usage: {
316
+ promptTokens: usage.prompt,
317
+ completionTokens: usage.completion,
318
+ totalTokens: usage.total,
319
+ cachedTokens: usage.cached,
320
+ turn: { ...turn.usage, finishReason: turn.finishReason },
321
+ },
322
+ });
224
323
  if (turn.finishReason === "length") {
225
324
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
226
325
  }
@@ -285,6 +384,7 @@ export async function runAgentLoop(options) {
285
384
  : {}),
286
385
  }, hooks.onNote);
287
386
  }
387
+ const metrics = runMetrics(recorded, { contextLength: config.contextLength });
288
388
  return {
289
389
  turn: shown,
290
390
  messages,
@@ -293,11 +393,17 @@ export async function runAgentLoop(options) {
293
393
  loaded: [...loaded],
294
394
  used: [...used],
295
395
  notes: gathered.notes,
396
+ metrics: {
397
+ ...metrics,
398
+ ...(onDemand ? loads : {}),
399
+ wallMs: Date.now() - started,
400
+ outcome: turn.finishReason === "length" ? "truncated" : "answered",
401
+ },
296
402
  };
297
403
  }
298
404
  const run = async ({ call, args, error: unreadable, normal }) => {
299
405
  const { name, arguments: raw } = call.function;
300
- onEvent?.({ kind: "tool-call", name, text: preview(raw) });
406
+ onEvent({ kind: "tool-call", name, text: preview(raw) });
301
407
  let content;
302
408
  let ok = true;
303
409
  try {
@@ -306,6 +412,9 @@ export async function runAgentLoop(options) {
306
412
  if (onDemand && name === LOAD_TOOLS) {
307
413
  const resolved = expandNames(requestedNames(args), catalog);
308
414
  content = loadResult(resolved, catalog, loaded);
415
+ for (const hit of resolved.matched)
416
+ loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
417
+ loads.unknownToolNames += resolved.unknown.length;
309
418
  for (const hit of resolved.matched)
310
419
  loaded.add(hit);
311
420
  ok = resolved.matched.length > 0;
@@ -328,7 +437,7 @@ export async function runAgentLoop(options) {
328
437
  content = errorMessage(error);
329
438
  ok = false;
330
439
  }
331
- onEvent?.({ kind: "tool-result", name, ok, text: preview(content) });
440
+ onEvent({ kind: "tool-result", name, ok, text: preview(content) });
332
441
  return { id: call.id, name, ok, content };
333
442
  };
334
443
  const outcomes = [];
@@ -0,0 +1,33 @@
1
+ import type OpenAI from "openai";
2
+ import type { Capabilities } from "./capabilities.ts";
3
+ /**
4
+ * The characters per token to size a request to this model with, `CHARS_PER_TOKEN` until a turn
5
+ * has reported one.
6
+ *
7
+ * The highest of the model's last few readings rather than their mean. The estimate guards a
8
+ * window, and `estimateTokens` says which side of wrong that should be on: a count that comes out
9
+ * high refuses a run that would have fit, one that comes out low only costs the round trip the
10
+ * guard was saving. The highest ratio is the lowest count, and the last few rather than all of
11
+ * them because a run's transcript grows by appending, so the latest requests are the best
12
+ * likeness of the next.
13
+ *
14
+ * @param supports The endpoint, as `capabilitiesFor` hands it over.
15
+ * @param model The name the endpoint knows the model as, as it goes in the body.
16
+ */
17
+ export declare function charsPerTokenFor(supports: Capabilities, model: string): number;
18
+ /**
19
+ * Takes one reading from a request that was answered, so the next one to this model is sized by it.
20
+ *
21
+ * `runTurn` calls it after every turn whose prompt was reported, so a caller using that has
22
+ * nothing to do. A request carrying an image or audio is not read: a vision model charges a
23
+ * picture hundreds of tokens its characters say nothing about. Neither is a reading outside what a
24
+ * tokenizer could produce, which is a miscount and not a tokenizer.
25
+ *
26
+ * @param supports The endpoint the request went to.
27
+ * @param body The request as it was last sent, which names the model.
28
+ * @param promptTokens The prompt count the endpoint reported for it. Zero or less is no report.
29
+ * @returns The ratio now in force for the model.
30
+ */
31
+ export declare function calibrate(supports: Capabilities, body: OpenAI.ChatCompletionCreateParamsStreaming, promptTokens: number): number;
32
+ /** Forgets every reading, so the next request is sized at `CHARS_PER_TOKEN` again. */
33
+ export declare function resetCalibration(): void;
@@ -0,0 +1,87 @@
1
+ import { CHARS_PER_TOKEN, requestChars, toolsChars } from "./retry.js";
2
+ /**
3
+ * How many characters a token is worth on one model, learned from what its endpoint reports.
4
+ *
5
+ * Four is right for English prose and wrong for everything a tool-using run is made of: JSON
6
+ * schemas and tool results pack closer to two or three, so the pre-flight guard let through
7
+ * requests the endpoint then refused. Every turn comes back with the exact prompt count for a
8
+ * request whose characters were already counted to size it, so the ratio is there for the taking.
9
+ *
10
+ * Kept apart from the capability latches, and out of `exportCapabilities`, on purpose. A latch is
11
+ * a refusal that holds until the process dies; this is a measurement that moves every turn and is
12
+ * learned again from the first one after a restart. `negotiate` compares every value on a
13
+ * `ModelCapabilities` to tell whether a flag moved under a request in flight, so a number that
14
+ * moves on every turn there would read as a latch changing and re-send refusals it should throw.
15
+ */
16
+ /** How many of a model's latest readings the ratio is taken over. */
17
+ const READINGS = 4;
18
+ /**
19
+ * The ratios no tokenizer produces over a whole request. Below one is a request whose tokens are
20
+ * mostly somewhere the characters do not count — an image — and far above four is a server that
21
+ * reported the uncached part of the prompt as all of it.
22
+ */
23
+ const PLAUSIBLE = { least: 1, most: 8 };
24
+ /**
25
+ * The latest readings per model, under the endpoint's own `Capabilities` object — the identity
26
+ * `capabilitiesFor` already gives one server and key — so an endpoint forgotten by
27
+ * `resetCapabilities` takes its readings with it. Replaced rather than cleared by `resetCalibration`,
28
+ * since a `WeakMap` has no `clear`.
29
+ */
30
+ let readings = new WeakMap();
31
+ /**
32
+ * The characters per token to size a request to this model with, `CHARS_PER_TOKEN` until a turn
33
+ * has reported one.
34
+ *
35
+ * The highest of the model's last few readings rather than their mean. The estimate guards a
36
+ * window, and `estimateTokens` says which side of wrong that should be on: a count that comes out
37
+ * high refuses a run that would have fit, one that comes out low only costs the round trip the
38
+ * guard was saving. The highest ratio is the lowest count, and the last few rather than all of
39
+ * them because a run's transcript grows by appending, so the latest requests are the best
40
+ * likeness of the next.
41
+ *
42
+ * @param supports The endpoint, as `capabilitiesFor` hands it over.
43
+ * @param model The name the endpoint knows the model as, as it goes in the body.
44
+ */
45
+ export function charsPerTokenFor(supports, model) {
46
+ const known = readings.get(supports)?.get(model);
47
+ return known?.length ? Math.max(...known) : CHARS_PER_TOKEN;
48
+ }
49
+ /** Whether any message carries a part whose tokens its characters do not count. */
50
+ const hasMedia = (messages) => messages.some(({ content }) => Array.isArray(content) &&
51
+ content.some((part) => part.type !== "text" && part.type !== "refusal"));
52
+ /**
53
+ * Takes one reading from a request that was answered, so the next one to this model is sized by it.
54
+ *
55
+ * `runTurn` calls it after every turn whose prompt was reported, so a caller using that has
56
+ * nothing to do. A request carrying an image or audio is not read: a vision model charges a
57
+ * picture hundreds of tokens its characters say nothing about. Neither is a reading outside what a
58
+ * tokenizer could produce, which is a miscount and not a tokenizer.
59
+ *
60
+ * @param supports The endpoint the request went to.
61
+ * @param body The request as it was last sent, which names the model.
62
+ * @param promptTokens The prompt count the endpoint reported for it. Zero or less is no report.
63
+ * @returns The ratio now in force for the model.
64
+ */
65
+ export function calibrate(supports, body, promptTokens) {
66
+ if (!(promptTokens > 0) || hasMedia(body.messages))
67
+ return charsPerTokenFor(supports, body.model);
68
+ const ratio = (requestChars(body) + toolsChars(body.tools ?? [])) / promptTokens;
69
+ if (ratio < PLAUSIBLE.least || ratio > PLAUSIBLE.most) {
70
+ return charsPerTokenFor(supports, body.model);
71
+ }
72
+ let models = readings.get(supports);
73
+ if (!models) {
74
+ models = new Map();
75
+ readings.set(supports, models);
76
+ }
77
+ const known = models.get(body.model) ?? [];
78
+ known.push(ratio);
79
+ if (known.length > READINGS)
80
+ known.shift();
81
+ models.set(body.model, known);
82
+ return charsPerTokenFor(supports, body.model);
83
+ }
84
+ /** Forgets every reading, so the next request is sized at `CHARS_PER_TOKEN` again. */
85
+ export function resetCalibration() {
86
+ readings = new WeakMap();
87
+ }
@@ -82,6 +82,14 @@ export interface ModelCapabilities {
82
82
  * endpoint's because one key reaches models that differ here, the way they differ on effort.
83
83
  */
84
84
  structuredOutput: boolean;
85
+ /**
86
+ * Continues a trailing assistant message rather than answering afresh, which `continueTurn`
87
+ * relies on. llama.cpp renders one as a prefill and picks up mid-word; hosted OpenAI takes the
88
+ * same request and writes a new reply after it, and some servers refuse it outright — llama.cpp
89
+ * itself does for a template with thinking enabled. Latched off by `continueTurn` on either,
90
+ * never by `negotiate`, since the refusal is about a request only a continuation sends.
91
+ */
92
+ assistantPrefill: boolean;
85
93
  }
86
94
  /**
87
95
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
@@ -56,6 +56,7 @@ export function modelCapabilitiesFor(supports, model) {
56
56
  chosenTemperature: true,
57
57
  refusedFields: new Set(),
58
58
  structuredOutput: true,
59
+ assistantPrefill: true,
59
60
  };
60
61
  supports.models.set(model, known);
61
62
  }