@cubicecho/agent-core 2.11.0 → 2.13.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
@@ -321,8 +365,17 @@ and — naming only tools that exist — a reply that is only a JSON call or hol
321
365
  Found calls are run as `call_recovered_0` onward, the text is what is left, `onTurn` and the
322
366
  result see the turn that way, and a notice says so, since the real fix is the server's parser.
323
367
 
368
+ Every request declares its tools in name order, whichever way the caller assembled the array. A
369
+ chat template renders the tool block ahead of the system prompt, so the tool array is the first
370
+ thing a prompt cache has to match, and an array built from a map, from database rows, or from the
371
+ order servers happened to connect in is a different array on the next boot — the same tools, the
372
+ same run, and the cache for the whole transcript thrown away. Ordering by name makes it a property
373
+ of the set instead. `toolOrder: false` sends the caller's order, for a host that means it — a model
374
+ reads the array top to bottom — and a comparator orders it another way. `orderTools` is the same
375
+ thing for a caller with its own loop, and `buildBody` takes the order as its last argument.
376
+
324
377
  With `toolDiscovery: "ondemand"` and a catalogue, the request declares `load_tools` and what has
325
- been loaded, appended in the order it was loaded, and the catalogue rides on the system prompt
378
+ been loaded, and the catalogue rides on the system prompt
326
379
  unmarked, the same text on every step. Marking loads there rewrote the head of the prompt and lost
327
380
  the prompt cache for the whole transcript on each one; a model that loads a tool twice is told in
328
381
  the `load_tools` result that it already has it. A model that calls
@@ -331,6 +384,55 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
331
384
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
332
385
  and everything is back from the second step on.
333
386
 
387
+ A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
388
+ continued first. `continueTurn` is the same thing for a caller with its own loop:
389
+
390
+ ```ts
391
+ let turn = await runTurn(client, supports, build, options);
392
+ turn = await continueTurn(client, supports, build, turn, { ...options, maxContinuations: 2 });
393
+ ```
394
+
395
+ It sends the transcript again with the answer so far as a trailing assistant message, which
396
+ llama.cpp renders as a prefill: the model carries on from the last token, and the cache holds all
397
+ of the prompt and most of the reply. vLLM only does so given `continue_final_message: true` and
398
+ `add_generation_prompt: false` on that request, which nothing here sends yet. The pieces come
399
+ back as one turn — content and reasoning joined, token counts summed, `continuations` counting the
400
+ extra requests, timings summed or weighted, a field only one piece reported dropped. Only an answer
401
+ begun and cut off is continued. A turn cut off in its scratchpad is left alone, since llama.cpp
402
+ refuses a prefill outright on a template with thinking on, and so is one ending in a tool call,
403
+ whose truncated arguments `parseToolArguments` already reports.
404
+
405
+ Whether it works is latched per model as `assistantPrefill`. A 400 or 422 for the request latches it
406
+ off, and so does a continuation that starts the answer over word for word — how hosted OpenAI, which
407
+ takes a trailing assistant message and ignores it, shows itself. Either way, and on any other
408
+ failure short of a stop, the cut-off answer is kept with a notice. The cap is one continuation for
409
+ `continueTurn` and none for the loop, since it spends a request, and on a server that does not
410
+ continue, one to find out.
411
+
412
+ Every turn ends in a `usage` event, whether or not the endpoint reported tokens. Its totals are the
413
+ run's so far; its `turn` is that turn's own `TurnUsage` and `finishReason`. The loop adds what only
414
+ it can know: `toolsDeclared` and `toolSchemaTokens` for the tool block it sent, and, from the second
415
+ request on, `cacheExpected` (the previous prompt plus its reply) and — where a cache count was
416
+ reported — `cacheBroken`, a hit short of 90% of the previous prompt. A broken cache is given a
417
+ `cacheBreakReason` read off the request against the one before: `tools-changed`, `system-changed`,
418
+ `history-rewritten` (a compaction or a prune in `beforeStep`), or `none-known` where the new request
419
+ only appended, which points at the server — a slot evicted, a template that re-renders the tail.
420
+
421
+ `runMetrics(events)` adds a run up from those events — tokens, cache hit ratio and breaks by
422
+ reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
423
+ acceptance, the largest prompt against a `contextLength`, turns cut off, tool errors by name, and
424
+ an `outcome`. The loop returns it as `metrics`, with the `load_tools` counts only it can see
425
+ (`toolsLoaded`, `redundantLoads`, `unknownToolNames`). It is a sibling of `fold` rather than part
426
+ of it, since `fold`'s blocks are for display and a summary is not one:
427
+
428
+ ```ts
429
+ const metrics = runMetrics(history(runId), { contextLength: config.contextLength });
430
+ ```
431
+
432
+ Counts are always present; every other field is absent where no turn reported what it is made of.
433
+ What it cannot say: whether a failed run was stopped, errored or ran out of tool iterations, and
434
+ whether the host compacted — neither is in the events.
435
+
334
436
  `beforeStep` is handed the transcript before each request and may return a replacement, which is
335
437
  where compaction goes (below). Hooks are gathered once, onto the question, and never written into
336
438
  the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
@@ -379,7 +481,7 @@ once `used` (the last turn's prompt tokens, or the estimate) is past three quart
379
481
  The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
380
482
  mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
381
483
  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
484
+ `beforeCompact` hooks what is going while it does. Because the cut lands on
383
485
  a user message, one long tool run under a single question has nothing to fold — pruning is what
384
486
  keeps that one going.
385
487
 
@@ -396,6 +498,21 @@ beforeStep: async (messages, step) => {
396
498
  },
397
499
  ```
398
500
 
501
+ A hook can ask for a compaction not to happen — its runner sets `veto` on the outcome — and by default
502
+ nobody listens: the hooks run beside the summary, so a slow one costs the run nothing. Pass
503
+ `honourVeto: true` with the hooks and they run first — the summary waits on them — and a veto from
504
+ any hook that ran leaves the transcript as it was, with a note naming the hook. One that failed
505
+ vetoes nothing. A compaction passed `forced: true`, because a request was already refused as too
506
+ big, goes ahead regardless: a veto there only trades the summary for a `ContextOverflow`.
507
+ `consult` is the same wait for a host that compacts its own way.
508
+
509
+ ```ts
510
+ const compacted = await compactTranscript(messages, plan, summarise, {
511
+ hooks: { run, context, onNote, honourVeto: true },
512
+ forced: retryingAfterOverflow, // the last request came back as a ContextOverflow
513
+ });
514
+ ```
515
+
399
516
  **Both rewrite the prefix.** A prompt cache matches from the first token, so a transcript whose
400
517
  early messages change is re-processed whole — on a local server that is the entire prefill, every
401
518
  time. Run them rarely and together, at the point `planCompaction` says the window is filling, so
@@ -405,6 +522,51 @@ way to save tokens.
405
522
  `pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
406
523
  what it returns, as above.
407
524
 
525
+ ### A fold you store, instead of a transcript you rewrite
526
+
527
+ `compactTranscript` hands back a new array, which is the whole answer for a host whose transcript
528
+ *is* that array. A host that keeps its messages append-only — rows in a database, every one still
529
+ shown in the chat — wants the other half: what the fold was, as something to store on the session.
530
+ `runCompaction` is `compactTranscript` without the rewrite. It returns `{ summary, through, at }`,
531
+ or `undefined` when a hook vetoed or the summary came back empty, and `compactTranscript` is built
532
+ out of it, so there is one summariser and one cut rather than two that drift.
533
+
534
+ ```ts
535
+ const from = session.fold?.through ?? 0;
536
+ const plan = planCompaction(session.messages, {
537
+ limit,
538
+ used,
539
+ from, // where the last fold ended, rather than scanning for it
540
+ previous: session.fold?.summary,
541
+ });
542
+ if (!plan) return;
543
+ const fold = await runCompaction(session.messages, plan, summarise, { hooks: { run, context } });
544
+ if (fold) await save(session.id, fold); // the messages themselves are never touched
545
+ ```
546
+
547
+ `planCompaction` takes `from` and `previous` because its defaults are a *recovery*: it skips the
548
+ leading `system` messages, and reads an earlier summary back out of a `SUMMARY_LEAD` message among
549
+ them. A host whose system prompt is a separate argument and whose summary is a column has neither
550
+ in the array, and knows both exactly. Given them, nothing is scanned.
551
+
552
+ `applyCompaction(messages, fold)` is the way back — the summary as a `system` message, then
553
+ everything from `through` — and it writes the same `SUMMARY_LEAD` `planCompaction` looks for, so
554
+ the next fold continues those notes rather than summarising them a second time. No fold yet hands
555
+ back the messages themselves.
556
+
557
+ ```ts
558
+ const request = [systemMessage, ...applyCompaction(session.messages, session.fold)];
559
+ ```
560
+
561
+ **Two numberings.** A stored transcript keeps its indexes and a folded request does not, so
562
+ anything naming a position has to say which one it means. `requestIndex(index, fold)` maps the
563
+ stored index onto the request — for `withContext`'s index, or a range being shown to a hook.
564
+ `turnMessages` takes an `offset`, the stored index of the array's first message, so a message keeps
565
+ the uuid it had before the fold and a memory server deduping on it files that turn once rather than
566
+ twice; `turnIndex` takes one too, for the turns a fold took out of the array it is counting.
567
+ Planning over the stored transcript, as above, sidesteps both: the plan's indexes are the host's
568
+ already, and so are the ones `runCompaction` hands the `beforeCompact` hooks.
569
+
408
570
  ## Watching a run
409
571
 
410
572
  `watch` replays what the run has already emitted, then yields what happens next until `done`.
@@ -579,7 +741,8 @@ with no timeout to give now leaves it out rather than inventing a `0`.
579
741
  ## What is kept for the life of the process
580
742
 
581
743
  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
744
+ capabilities, and `side-task`'s no-thinking hints. A fifth, the characters per token each model was
745
+ 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
746
  notion of an endpoint — its base URL and its API key — and the clients' key carries the request
584
747
  timeout as well, since that changes how a request is sent.
585
748
 
@@ -619,7 +782,8 @@ URL and the key, an absent key read as `NO_KEY` — rather than rebuilding that
619
782
  cannot drift. `endpointKey` holds the key in the clear; `endpointId`, its SHA-256 digest, is the one
620
783
  that is safe to write down.
621
784
 
622
- `resetAll` drops all four, and `reset.ts` names each seam separately for a test that wants one.
785
+ `resetAll` drops all five, and `reset.ts` names each seam separately for a test that wants one
786
+ `resetCalibration` for the measured ratios.
623
787
 
624
788
  The latches can outlive the process as well, because otherwise every restart spends one refused
625
789
  request per endpoint and model learning the same facts again. `exportCapabilities` returns every
@@ -2,9 +2,10 @@ 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
+ import { type ToolOrder } from "./tool-loading.ts";
8
9
  /**
9
10
  * The one place a streamed request's body is decided from a config and what the endpoint and
10
11
  * the model have refused.
@@ -22,10 +23,13 @@ import type { Turn, TurnUsage } from "./stream.ts";
22
23
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
23
24
  * that has refused nothing.
24
25
  * @param messages The request's messages, system prompt included, sent as they are.
25
- * @param tools The tool definitions. Sanitised here — a lookup for a definition seen before —
26
- * and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
26
+ * @param tools The tool definitions. Ordered by name, sanitised here — a lookup for a definition
27
+ * seen before — and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
28
+ * @param order How to order them before sending. `true`, the default, is by name, which keeps the
29
+ * cache when the caller's array is assembled differently from one request to the next. See
30
+ * `orderTools`.
27
31
  */
28
- export declare function buildBody(config: ModelParams, supports: Capabilities, refused: ModelCapabilities | undefined, messages: OpenAI.ChatCompletionMessageParam[], tools?: OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionCreateParamsStreaming;
32
+ export declare function buildBody(config: ModelParams, supports: Capabilities, refused: ModelCapabilities | undefined, messages: OpenAI.ChatCompletionMessageParam[], tools?: OpenAI.ChatCompletionTool[], order?: ToolOrder): OpenAI.ChatCompletionCreateParamsStreaming;
29
33
  /**
30
34
  * A long tool argument or result cut to what a watcher needs, with the full length said.
31
35
  *
@@ -131,6 +135,12 @@ export interface AgentLoopOptions {
131
135
  tools?: OpenAI.ChatCompletionTool[];
132
136
  /** The same tools as a name-only catalogue. On-demand mode needs it, and is eager without it. */
133
137
  catalog?: CatalogServer[];
138
+ /**
139
+ * How the declared tools are ordered before each request. By name unless told otherwise, so a
140
+ * run whose tool array was assembled in a different order than last time still meets its cache.
141
+ * `false` sends them as given. See `orderTools`.
142
+ */
143
+ toolOrder?: ToolOrder;
134
144
  /**
135
145
  * What `preselect` picked. The first step is sent these and nothing else — no catalogue, no
136
146
  * `load_tools` — because a model with the menu still in front of it shops: it reloads what it
@@ -168,6 +178,12 @@ export interface AgentLoopOptions {
168
178
  recoverToolCalls?: boolean;
169
179
  /** Each turn as it comes back, before its tools run. Recovered calls are in it as calls. */
170
180
  onTurn?: (turn: Turn, step: number) => void;
181
+ /**
182
+ * How many times an answer cut off at `maxTokens` is continued, zero — the default — for never.
183
+ * Opt-in because it spends another request, and on a server that does not continue a trailing
184
+ * assistant message it spends one to find that out. See `continueTurn`.
185
+ */
186
+ maxContinuations?: number;
171
187
  }
172
188
  /** What a finished loop hands back. */
173
189
  export interface AgentLoopResult {
@@ -185,6 +201,11 @@ export interface AgentLoopResult {
185
201
  used: string[];
186
202
  /** The hooks' notes from before the first request. */
187
203
  notes: HookNote[];
204
+ /**
205
+ * The run summed and derived: what `runMetrics` makes of the events this loop emitted, plus the
206
+ * `load_tools` findings only the loop sees, `wallMs` from the call to the return, and `outcome`.
207
+ */
208
+ metrics: RunMetrics;
188
209
  }
189
210
  /**
190
211
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
@@ -192,10 +213,13 @@ export interface AgentLoopResult {
192
213
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
193
214
  *
194
215
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
195
- * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
216
+ * prompt unchanged from step to step, a load adds to the tool array which every request sends
217
+ * in the stable order `toolOrder` asks for — and
196
218
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
197
219
  * 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.
220
+ * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
221
+ * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
222
+ * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
199
223
  *
200
224
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
201
225
  * and cancellation. See `AgentLoopOptions`.
@@ -1,12 +1,16 @@
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";
8
12
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
9
- import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
13
+ import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
10
14
  /**
11
15
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
12
16
  * asking.
@@ -36,11 +40,17 @@ const RESERVED = new Set(["model", "messages", "stream", "tools"]);
36
40
  * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
37
41
  * that has refused nothing.
38
42
  * @param messages The request's messages, system prompt included, sent as they are.
39
- * @param tools The tool definitions. Sanitised here — a lookup for a definition seen before —
40
- * and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
43
+ * @param tools The tool definitions. Ordered by name, sanitised here — a lookup for a definition
44
+ * seen before — and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
45
+ * @param order How to order them before sending. `true`, the default, is by name, which keeps the
46
+ * cache when the caller's array is assembled differently from one request to the next. See
47
+ * `orderTools`.
41
48
  */
42
- export function buildBody(config, supports, refused, messages, tools = []) {
43
- const declared = supports.strictSchemas ? sanitizeTools(tools) : relaxTools(sanitizeTools(tools));
49
+ export function buildBody(config, supports, refused, messages, tools = [], order = true) {
50
+ const sorted = orderTools(tools, order);
51
+ const declared = supports.strictSchemas
52
+ ? sanitizeTools(sorted)
53
+ : relaxTools(sanitizeTools(sorted));
44
54
  const effort = config.reasoningEffort;
45
55
  const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
46
56
  return {
@@ -119,32 +129,107 @@ export async function preselect(config, model, catalog, prompt, { signal, onNoti
119
129
  const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
120
130
  return preselection(reply, catalog, maxPerLoad);
121
131
  }
122
- /** Every numeric field of one usage added into another. */
132
+ /**
133
+ * The four token counts of one usage added into another. Only those: the rest are measurements a
134
+ * sum of would mean nothing, or would mean something only `runMetrics` knows how to weigh.
135
+ */
123
136
  const accumulate = (total, turn) => {
124
- for (const key of Object.keys(turn))
125
- total[key] += turn[key] ?? 0;
137
+ total.prompt += turn.prompt;
138
+ total.completion += turn.completion;
139
+ total.total += turn.total;
140
+ total.cached += turn.cached;
141
+ };
142
+ /** Whether two messages say the same thing, by identity first since most of a transcript is. */
143
+ const sameMessage = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
144
+ /** The system messages a request opens with, which a template renders ahead of the history. */
145
+ const leadingSystem = (messages) => {
146
+ const end = messages.findIndex((message) => message.role !== "system");
147
+ return messages.slice(0, end === -1 ? messages.length : end);
126
148
  };
149
+ /**
150
+ * Where a request stopped matching the one before it, earliest in the rendered prompt first — the
151
+ * tool block, then the system prompt, then the history — or `none-known` where it only appended.
152
+ */
153
+ function breakReason(previous, messages, tools) {
154
+ if (previous.tools.length !== tools.length ||
155
+ previous.tools.some((name, at) => name !== tools[at]))
156
+ return "tools-changed";
157
+ const before = leadingSystem(previous.messages);
158
+ const now = leadingSystem(messages);
159
+ if (before.length !== now.length || before.some((message, at) => !sameMessage(message, now[at])))
160
+ return "system-changed";
161
+ if (previous.messages.length > messages.length ||
162
+ previous.messages.some((message, at) => !sameMessage(message, messages[at])))
163
+ return "history-rewritten";
164
+ return "none-known";
165
+ }
166
+ /**
167
+ * The share of the previous prompt a cache that kept its prefix reports as hit. Short of it by
168
+ * more than this is a break, not the few tokens a template re-renders at the join.
169
+ */
170
+ const CACHE_KEPT = 0.9;
171
+ /**
172
+ * What one turn's cache should have been and whether it was, against the request before it.
173
+ * Nothing before the second request, or where the previous prompt was not reported.
174
+ */
175
+ function cacheDiagnosis(previous, messages, tools, usage) {
176
+ if (!previous || !(previous.prompt > 0))
177
+ return {};
178
+ const found = { cacheExpected: previous.prompt + previous.completion };
179
+ if (usage.uncached === undefined)
180
+ return found;
181
+ found.cacheBroken = usage.cached < previous.prompt * CACHE_KEPT;
182
+ if (found.cacheBroken)
183
+ found.cacheBreakReason = breakReason(previous, messages, tools);
184
+ return found;
185
+ }
127
186
  /**
128
187
  * Runs a question to its answer: one `runTurn` per step, the tools it asks for between them,
129
188
  * until a turn asks for none. Throws when `maxToolIterations` is spent, when stopped, and on
130
189
  * whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
131
190
  *
132
191
  * On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
133
- * prompt unchanged from step to step, loaded tools are appended to the tool array in load order,
192
+ * prompt unchanged from step to step, a load adds to the tool array which every request sends
193
+ * in the stable order `toolOrder` asks for — and
134
194
  * a catalogued tool called without being loaded is loaded and run rather than refused, and a
135
195
  * 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.
196
+ * because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
197
+ * continued first. Every turn ends in a `usage` event carrying the turn's own report, with the
198
+ * cache compared against the request before it; see `TurnUsage` and `runMetrics`.
137
199
  *
138
200
  * @param options The config, transcript, tools and dispatcher, plus the optional hooks, events
139
201
  * and cancellation. See `AgentLoopOptions`.
140
202
  */
141
203
  export async function runAgentLoop(options) {
142
204
  const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
143
- const { onEvent, onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, } = options;
205
+ const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, } = options;
206
+ const started = Date.now();
207
+ // What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
208
+ // than by the bus, which the loop does not know about.
209
+ const recorded = [];
210
+ const record = (input) => {
211
+ if (input.kind === "thinking" || input.kind === "output")
212
+ return;
213
+ recorded.push({
214
+ runId: "",
215
+ seq: recorded.length + 1,
216
+ at: Date.now(),
217
+ text: "",
218
+ name: "",
219
+ step: "",
220
+ ok: null,
221
+ usage: null,
222
+ ...input,
223
+ });
224
+ };
225
+ const onEvent = (input) => {
226
+ record(input);
227
+ options.onEvent?.(input);
228
+ };
144
229
  const client = getClient(config);
145
230
  const supports = capabilitiesFor(config.baseUrl, config.apiKey);
146
231
  const maxRetries = Math.max(0, Number(config.maxRetries) || 0);
147
- const notice = (text) => onEvent?.({ kind: "notice", text });
232
+ const notice = (text) => onEvent({ kind: "notice", text });
148
233
  const onDemand = config.toolDiscovery === "ondemand" && catalog.length > 0;
149
234
  const loaded = new Set(onDemand ? (options.loaded ?? []) : []);
150
235
  const preselected = onDemand ? [...(options.preselected ?? [])] : [];
@@ -175,18 +260,23 @@ export async function runAgentLoop(options) {
175
260
  const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
176
261
  const toolCalls = [];
177
262
  const answered = new Map();
263
+ const loads = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: 0 };
264
+ let previous;
178
265
  for (let step = 0; step < config.maxToolIterations; step++) {
179
266
  // A stop aborts the request in flight, but a tool call already handed off runs to its own
180
267
  // end — so the signal is read between steps as well.
181
268
  signal?.throwIfAborted();
182
269
  messages = (await beforeStep?.(messages, step)) ?? messages;
183
- onEvent?.({ kind: "turn", text: `turn ${step + 1}` });
270
+ onEvent({ kind: "turn", text: `turn ${step + 1}` });
184
271
  const routed = preselected.length > 0 && step === 0;
185
- const declared = routed
272
+ // Ordered here rather than left to `buildBody`, so `names` below is what the request actually
273
+ // declared — a diagnosis reading an order the server never saw calls an untouched tool array
274
+ // `tools-changed`.
275
+ const declared = orderTools(routed
186
276
  ? byName(new Set(preselected))
187
277
  : onDemand
188
278
  ? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
189
- : tools;
279
+ : tools, toolOrder);
190
280
  // Unmarked, so the system prompt is the same text on every step and a load does not throw
191
281
  // away the cache for the whole transcript. What is loaded is said in `declared` and in the
192
282
  // `load_tools` result instead. The preselected first step is the one exception, by design.
@@ -195,7 +285,8 @@ export async function runAgentLoop(options) {
195
285
  ...(prompt ? [{ role: "system", content: prompt }] : []),
196
286
  ...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
197
287
  ];
198
- const turn = await runTurn(client, supports, (supported, refused) => buildBody(config, supported, refused, request, declared), {
288
+ const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
289
+ const turnOptions = {
199
290
  model: config.model,
200
291
  droppable: Object.keys(config.extraBody ?? {}),
201
292
  maxRetries,
@@ -207,20 +298,38 @@ export async function runAgentLoop(options) {
207
298
  idleMs: timeoutMs(config),
208
299
  firstChunkMs: firstTokenMs(config) ?? 0,
209
300
  onNotice: notice,
210
- onThinking: (text) => onEvent?.({ kind: "thinking", text }),
211
- onOutput: (text) => onEvent?.({ kind: "output", text }),
301
+ onThinking: (text) => onEvent({ kind: "thinking", text }),
302
+ onOutput: (text) => onEvent({ kind: "output", text }),
303
+ };
304
+ const first = await runTurn(client, supports, build, turnOptions);
305
+ const names = declared.map((tool) => (tool.type === "function" ? tool.function.name : ""));
306
+ // Compared before any continuation is joined on: the cache a request meets is the one its own
307
+ // prompt found, and a continuation's prompt is this request's plus the reply so far.
308
+ Object.assign(first.usage, cacheDiagnosis(previous, request, names, first.usage), {
309
+ toolsDeclared: declared.length,
310
+ toolSchemaTokens: Math.ceil(toolsChars(declared) / charsPerTokenFor(supports, config.model)),
212
311
  });
312
+ const firstPrompt = first.usage.prompt;
313
+ const turn = maxContinuations > 0
314
+ ? await continueTurn(client, supports, build, first, { ...turnOptions, maxContinuations })
315
+ : first;
316
+ previous = {
317
+ messages: request,
318
+ tools: names,
319
+ prompt: firstPrompt,
320
+ completion: turn.usage.completion,
321
+ };
213
322
  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
- }
323
+ onEvent({
324
+ kind: "usage",
325
+ usage: {
326
+ promptTokens: usage.prompt,
327
+ completionTokens: usage.completion,
328
+ totalTokens: usage.total,
329
+ cachedTokens: usage.cached,
330
+ turn: { ...turn.usage, finishReason: turn.finishReason },
331
+ },
332
+ });
224
333
  if (turn.finishReason === "length") {
225
334
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
226
335
  }
@@ -285,6 +394,7 @@ export async function runAgentLoop(options) {
285
394
  : {}),
286
395
  }, hooks.onNote);
287
396
  }
397
+ const metrics = runMetrics(recorded, { contextLength: config.contextLength });
288
398
  return {
289
399
  turn: shown,
290
400
  messages,
@@ -293,11 +403,17 @@ export async function runAgentLoop(options) {
293
403
  loaded: [...loaded],
294
404
  used: [...used],
295
405
  notes: gathered.notes,
406
+ metrics: {
407
+ ...metrics,
408
+ ...(onDemand ? loads : {}),
409
+ wallMs: Date.now() - started,
410
+ outcome: turn.finishReason === "length" ? "truncated" : "answered",
411
+ },
296
412
  };
297
413
  }
298
414
  const run = async ({ call, args, error: unreadable, normal }) => {
299
415
  const { name, arguments: raw } = call.function;
300
- onEvent?.({ kind: "tool-call", name, text: preview(raw) });
416
+ onEvent({ kind: "tool-call", name, text: preview(raw) });
301
417
  let content;
302
418
  let ok = true;
303
419
  try {
@@ -306,6 +422,9 @@ export async function runAgentLoop(options) {
306
422
  if (onDemand && name === LOAD_TOOLS) {
307
423
  const resolved = expandNames(requestedNames(args), catalog);
308
424
  content = loadResult(resolved, catalog, loaded);
425
+ for (const hit of resolved.matched)
426
+ loads[loaded.has(hit) ? "redundantLoads" : "toolsLoaded"]++;
427
+ loads.unknownToolNames += resolved.unknown.length;
309
428
  for (const hit of resolved.matched)
310
429
  loaded.add(hit);
311
430
  ok = resolved.matched.length > 0;
@@ -318,7 +437,7 @@ export async function runAgentLoop(options) {
318
437
  used.add(name);
319
438
  const request = { id: call.id, name, args, raw };
320
439
  content = parallel
321
- ? await once(answered, `${name}${normal}`, () => dispatch(request, signal))
440
+ ? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
322
441
  : await dispatch(request, signal);
323
442
  }
324
443
  }
@@ -328,7 +447,7 @@ export async function runAgentLoop(options) {
328
447
  content = errorMessage(error);
329
448
  ok = false;
330
449
  }
331
- onEvent?.({ kind: "tool-result", name, ok, text: preview(content) });
450
+ onEvent({ kind: "tool-result", name, ok, text: preview(content) });
332
451
  return { id: call.id, name, ok, content };
333
452
  };
334
453
  const outcomes = [];