@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 +169 -5
- package/dist/agent-loop.d.ts +30 -6
- package/dist/agent-loop.js +150 -31
- 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 +130 -16
- package/dist/compaction.js +137 -32
- 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 +49 -3
- package/dist/hooks.js +59 -4
- package/dist/index.d.ts +7 -5
- package/dist/index.js +7 -5
- 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/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/dist/tool-loading.d.ts +29 -4
- package/dist/tool-loading.js +33 -4
- package/llms.txt +33 -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
|
|
@@ -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,
|
|
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.
|
|
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.
|
|
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
|
|
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
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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,
|
|
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`.
|
package/dist/agent-loop.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
125
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
|
270
|
+
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
184
271
|
const routed = preselected.length > 0 && step === 0;
|
|
185
|
-
|
|
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
|
|
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
|
|
211
|
-
onOutput: (text) => onEvent
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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}
|
|
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
|
|
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 = [];
|