@cubicecho/agent-core 2.10.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 +129 -4
- package/dist/agent-loop.d.ts +15 -2
- package/dist/agent-loop.js +131 -22
- package/dist/calibration.d.ts +33 -0
- package/dist/calibration.js +87 -0
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.js +1 -0
- package/dist/compaction.d.ts +26 -8
- package/dist/compaction.js +36 -17
- package/dist/continuation.d.ts +59 -0
- package/dist/continuation.js +159 -0
- package/dist/events.d.ts +112 -1
- package/dist/events.js +111 -11
- package/dist/hooks.d.ts +29 -1
- package/dist/hooks.js +36 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -3
- package/dist/reset.d.ts +7 -6
- package/dist/reset.js +9 -6
- package/dist/retry.d.ts +36 -3
- package/dist/retry.js +49 -16
- package/dist/run-turn.d.ts +4 -0
- package/dist/run-turn.js +19 -3
- package/dist/side-task.d.ts +17 -4
- package/dist/side-task.js +2 -2
- package/dist/snapshot.d.ts +5 -0
- package/dist/snapshot.js +5 -0
- package/dist/stream.d.ts +74 -2
- package/dist/stream.js +45 -5
- package/llms.txt +27 -1
- package/package.json +1 -1
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
|
|
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
|
|
@@ -202,6 +222,21 @@ mean every property required and `additionalProperties: false`; a looser schema
|
|
|
202
222
|
`preselect` is its first user, answering `{ tools: [...] }`, and `preselection` still takes the
|
|
203
223
|
bare array an older prompt produced.
|
|
204
224
|
|
|
225
|
+
### Showing a side task an image
|
|
226
|
+
|
|
227
|
+
`ask` and `askJson` take the user turn as a string or as content parts, because an image reaches an
|
|
228
|
+
OpenAI-compatible server only as an `image_url` part beside the text. The parts are sent as given
|
|
229
|
+
and nothing here checks that the model can see: whether a text-only model rejects the image or
|
|
230
|
+
answers without it is up to the server, so pick a vision model.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
const page: SideTaskInput = [
|
|
234
|
+
{ type: "text", text: "Transcribe this page." },
|
|
235
|
+
{ type: "image_url", image_url: { url: `data:image/png;base64,${png}` } },
|
|
236
|
+
];
|
|
237
|
+
const text = await ask(config, visionModel, "You are an OCR engine.", page);
|
|
238
|
+
```
|
|
239
|
+
|
|
205
240
|
## Sizing a request before sending it
|
|
206
241
|
|
|
207
242
|
`runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
|
|
@@ -236,6 +271,30 @@ nothing, and the server gives the reply whatever the prompt leaves. A consumer t
|
|
|
236
271
|
`maxTokens` from the limit itself before calling in no longer needs to, and doing both reserves
|
|
237
272
|
the ceiling twice.
|
|
238
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
|
+
|
|
239
298
|
The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
|
|
240
299
|
before it and the transcript does not change between retries. A `ContextOverflow` from this is
|
|
241
300
|
neither a capability `negotiate` can answer nor something `isTransient` accepts, so it leaves
|
|
@@ -316,6 +375,55 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
|
|
|
316
375
|
a model with the menu still in front of it shops, reloading what it has or picking a sibling —
|
|
317
376
|
and everything is back from the second step on.
|
|
318
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
|
+
|
|
319
427
|
`beforeStep` is handed the transcript before each request and may return a replacement, which is
|
|
320
428
|
where compaction goes (below). Hooks are gathered once, onto the question, and never written into
|
|
321
429
|
the transcript that comes back; `afterTurn` is told the reply without the run waiting on it.
|
|
@@ -364,7 +472,7 @@ once `used` (the last turn's prompt tokens, or the estimate) is past three quart
|
|
|
364
472
|
The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
|
|
365
473
|
mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
|
|
366
474
|
summary is continued rather than summarised. `compactTranscript` writes the summary and tells
|
|
367
|
-
`beforeCompact` hooks what is going while it does.
|
|
475
|
+
`beforeCompact` hooks what is going while it does. Because the cut lands on
|
|
368
476
|
a user message, one long tool run under a single question has nothing to fold — pruning is what
|
|
369
477
|
keeps that one going.
|
|
370
478
|
|
|
@@ -381,6 +489,21 @@ beforeStep: async (messages, step) => {
|
|
|
381
489
|
},
|
|
382
490
|
```
|
|
383
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
|
+
|
|
384
507
|
**Both rewrite the prefix.** A prompt cache matches from the first token, so a transcript whose
|
|
385
508
|
early messages change is re-processed whole — on a local server that is the entire prefill, every
|
|
386
509
|
time. Run them rarely and together, at the point `planCompaction` says the window is filling, so
|
|
@@ -564,7 +687,8 @@ with no timeout to give now leaves it out rather than inventing a `0`.
|
|
|
564
687
|
## What is kept for the life of the process
|
|
565
688
|
|
|
566
689
|
Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
|
|
567
|
-
capabilities, and `side-task`'s no-thinking hints.
|
|
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
|
|
568
692
|
notion of an endpoint — its base URL and its API key — and the clients' key carries the request
|
|
569
693
|
timeout as well, since that changes how a request is sent.
|
|
570
694
|
|
|
@@ -604,7 +728,8 @@ URL and the key, an absent key read as `NO_KEY` — rather than rebuilding that
|
|
|
604
728
|
cannot drift. `endpointKey` holds the key in the clear; `endpointId`, its SHA-256 digest, is the one
|
|
605
729
|
that is safe to write down.
|
|
606
730
|
|
|
607
|
-
`resetAll` drops all
|
|
731
|
+
`resetAll` drops all five, and `reset.ts` names each seam separately for a test that wants one —
|
|
732
|
+
`resetCalibration` for the measured ratios.
|
|
608
733
|
|
|
609
734
|
The latches can outlive the process as well, because otherwise every restart spends one refused
|
|
610
735
|
request per endpoint and model learning the same facts again. `exportCapabilities` returns every
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -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
|
|
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`.
|
package/dist/agent-loop.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
125
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
211
|
-
onOutput: (text) => onEvent
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -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`
|