@cubicecho/agent-core 2.12.0 → 2.14.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 +114 -3
- package/dist/agent-loop.d.ts +15 -4
- package/dist/agent-loop.js +21 -11
- package/dist/capabilities.d.ts +46 -2
- package/dist/capabilities.js +48 -4
- package/dist/compaction.d.ts +112 -16
- package/dist/compaction.js +118 -32
- package/dist/hooks.d.ts +20 -2
- package/dist/hooks.js +23 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/retry.d.ts +60 -0
- package/dist/retry.js +83 -0
- package/dist/snapshot.d.ts +11 -2
- package/dist/snapshot.js +18 -4
- package/dist/tool-loading.d.ts +29 -4
- package/dist/tool-loading.js +33 -4
- package/llms.txt +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ only, Node >=22.
|
|
|
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
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
|
-
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `isModelLoading`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
|
|
32
|
+
| `retry` | What to do when a request is lost, refused or too big: `isTransient`, `isModelLoading`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`, `contextTokens`. |
|
|
33
33
|
| `calibration` | How many characters a token is worth on one model, learned from the prompt counts its endpoint reports: `charsPerTokenFor`, `calibrate`. |
|
|
34
34
|
| `continuation` | `continueTurn`: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message. |
|
|
35
35
|
| `config` | The structural interfaces every function here asks for. |
|
|
@@ -314,6 +314,35 @@ for up to `loadingTimeoutMs` (two minutes by default, zero to turn it off) witho
|
|
|
314
314
|
`maxRetries`, with one notice at the start. `runAgentLoop` reads it as `loadingTimeoutSeconds` off
|
|
315
315
|
the config. A 503 that says nothing about loading stays on the ordinary backoff.
|
|
316
316
|
|
|
317
|
+
### What is filling the window
|
|
318
|
+
|
|
319
|
+
A total tells an operator a run is close to the edge and nothing about what to do next, so
|
|
320
|
+
`contextTokens` cuts the same body four ways, along the four levers there are: `system` is the
|
|
321
|
+
system and developer messages, which means shortening the prompt; `tools` is the declared schemas,
|
|
322
|
+
which means loading them on demand instead of declaring them whole; `toolResults` is exactly the
|
|
323
|
+
`tool` messages, which is precisely what `pruneToolResults` shrinks; and `history` is everything
|
|
324
|
+
else, which is what compaction folds, the arguments of the calls in it included.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
const { system, tools, history, toolResults, total } = contextTokens(body, {
|
|
328
|
+
charsPerToken: charsPerTokenFor(supports, config.model),
|
|
329
|
+
// Optional: what the endpoint said the prompt cost, once a turn has come back.
|
|
330
|
+
promptTokens: turn.usage?.prompt_tokens,
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The parts are shares of one total rather than four separate estimates, because a readout whose
|
|
335
|
+
parts do not add up to the number beside them is one nobody trusts; the largest part absorbs the
|
|
336
|
+
rounding, so they sum exactly. Nothing in the round trip reports anything finer than a prompt
|
|
337
|
+
count — a completion says how many tokens it read and not a word about where they came from — so
|
|
338
|
+
the proportions are a guess whatever the total is, and `contextChars` is there for a caller that
|
|
339
|
+
wants the exact characters underneath them.
|
|
340
|
+
|
|
341
|
+
Given a `promptTokens` the total is what was charged and every part is a share of it. Without one
|
|
342
|
+
the total is `requestTokens`, and the tool block is counted the way `requestTokens` and
|
|
343
|
+
`TurnMetrics.toolSchemaTokens` count it rather than shared out, so the breakdown and the metrics
|
|
344
|
+
line cannot disagree about the same tool list.
|
|
345
|
+
|
|
317
346
|
## The loop
|
|
318
347
|
|
|
319
348
|
`runAgentLoop` is the part of an agent that three servers had each written, and that had drifted
|
|
@@ -365,8 +394,17 @@ and — naming only tools that exist — a reply that is only a JSON call or hol
|
|
|
365
394
|
Found calls are run as `call_recovered_0` onward, the text is what is left, `onTurn` and the
|
|
366
395
|
result see the turn that way, and a notice says so, since the real fix is the server's parser.
|
|
367
396
|
|
|
397
|
+
Every request declares its tools in name order, whichever way the caller assembled the array. A
|
|
398
|
+
chat template renders the tool block ahead of the system prompt, so the tool array is the first
|
|
399
|
+
thing a prompt cache has to match, and an array built from a map, from database rows, or from the
|
|
400
|
+
order servers happened to connect in is a different array on the next boot — the same tools, the
|
|
401
|
+
same run, and the cache for the whole transcript thrown away. Ordering by name makes it a property
|
|
402
|
+
of the set instead. `toolOrder: false` sends the caller's order, for a host that means it — a model
|
|
403
|
+
reads the array top to bottom — and a comparator orders it another way. `orderTools` is the same
|
|
404
|
+
thing for a caller with its own loop, and `buildBody` takes the order as its last argument.
|
|
405
|
+
|
|
368
406
|
With `toolDiscovery: "ondemand"` and a catalogue, the request declares `load_tools` and what has
|
|
369
|
-
been loaded,
|
|
407
|
+
been loaded, and the catalogue rides on the system prompt
|
|
370
408
|
unmarked, the same text on every step. Marking loads there rewrote the head of the prompt and lost
|
|
371
409
|
the prompt cache for the whole transcript on each one; a model that loads a tool twice is told in
|
|
372
410
|
the `load_tools` result that it already has it. A model that calls
|
|
@@ -513,6 +551,51 @@ way to save tokens.
|
|
|
513
551
|
`pruneToolResults` keeps the transcript's indexes, so a plan made before pruning still applies to
|
|
514
552
|
what it returns, as above.
|
|
515
553
|
|
|
554
|
+
### A fold you store, instead of a transcript you rewrite
|
|
555
|
+
|
|
556
|
+
`compactTranscript` hands back a new array, which is the whole answer for a host whose transcript
|
|
557
|
+
*is* that array. A host that keeps its messages append-only — rows in a database, every one still
|
|
558
|
+
shown in the chat — wants the other half: what the fold was, as something to store on the session.
|
|
559
|
+
`runCompaction` is `compactTranscript` without the rewrite. It returns `{ summary, through, at }`,
|
|
560
|
+
or `undefined` when a hook vetoed or the summary came back empty, and `compactTranscript` is built
|
|
561
|
+
out of it, so there is one summariser and one cut rather than two that drift.
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
const from = session.fold?.through ?? 0;
|
|
565
|
+
const plan = planCompaction(session.messages, {
|
|
566
|
+
limit,
|
|
567
|
+
used,
|
|
568
|
+
from, // where the last fold ended, rather than scanning for it
|
|
569
|
+
previous: session.fold?.summary,
|
|
570
|
+
});
|
|
571
|
+
if (!plan) return;
|
|
572
|
+
const fold = await runCompaction(session.messages, plan, summarise, { hooks: { run, context } });
|
|
573
|
+
if (fold) await save(session.id, fold); // the messages themselves are never touched
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`planCompaction` takes `from` and `previous` because its defaults are a *recovery*: it skips the
|
|
577
|
+
leading `system` messages, and reads an earlier summary back out of a `SUMMARY_LEAD` message among
|
|
578
|
+
them. A host whose system prompt is a separate argument and whose summary is a column has neither
|
|
579
|
+
in the array, and knows both exactly. Given them, nothing is scanned.
|
|
580
|
+
|
|
581
|
+
`applyCompaction(messages, fold)` is the way back — the summary as a `system` message, then
|
|
582
|
+
everything from `through` — and it writes the same `SUMMARY_LEAD` `planCompaction` looks for, so
|
|
583
|
+
the next fold continues those notes rather than summarising them a second time. No fold yet hands
|
|
584
|
+
back the messages themselves.
|
|
585
|
+
|
|
586
|
+
```ts
|
|
587
|
+
const request = [systemMessage, ...applyCompaction(session.messages, session.fold)];
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Two numberings.** A stored transcript keeps its indexes and a folded request does not, so
|
|
591
|
+
anything naming a position has to say which one it means. `requestIndex(index, fold)` maps the
|
|
592
|
+
stored index onto the request — for `withContext`'s index, or a range being shown to a hook.
|
|
593
|
+
`turnMessages` takes an `offset`, the stored index of the array's first message, so a message keeps
|
|
594
|
+
the uuid it had before the fold and a memory server deduping on it files that turn once rather than
|
|
595
|
+
twice; `turnIndex` takes one too, for the turns a fold took out of the array it is counting.
|
|
596
|
+
Planning over the stored transcript, as above, sidesteps both: the plan's indexes are the host's
|
|
597
|
+
already, and so are the ones `runCompaction` hands the `beforeCompact` hooks.
|
|
598
|
+
|
|
516
599
|
## Watching a run
|
|
517
600
|
|
|
518
601
|
`watch` replays what the run has already emitted, then yields what happens next until `done`.
|
|
@@ -745,7 +828,35 @@ A snapshot names endpoints by `endpointId`, a SHA-256 digest of the URL and key,
|
|
|
745
828
|
written to a settings row or a file without a credential going with it. Importing merges and only
|
|
746
829
|
latches off, the same as a refusal does. A snapshot of another `version` is ignored. How old is too
|
|
747
830
|
old is left to the consumer, who can read `savedAt` first: a server upgraded between boots may
|
|
748
|
-
accept what it used to refuse, and nothing latched ever unlatches
|
|
831
|
+
accept what it used to refuse, and nothing latched ever unlatches on its own.
|
|
832
|
+
|
|
833
|
+
It unlatches when you say so. A server upgraded behind the same URL — a newer llama.cpp that
|
|
834
|
+
compiles the grammar, a proxy that has learned `stream_options` — keeps being sent the downgraded
|
|
835
|
+
request until something forgets what it refused, and `resetCapabilities` takes the endpoint to
|
|
836
|
+
forget:
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
resetCapabilities({ baseUrl: row.baseUrl, apiKey: row.apiKey }); // this one changed
|
|
840
|
+
expireCapabilities(6 * 60 * 60_000); // anything half a day old
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
`resetCapabilities` with no argument still clears every endpoint, which is what `resetAll` and a
|
|
844
|
+
test mean by it; with one it clears that endpoint alone, so an upgraded local box does not cost the
|
|
845
|
+
cloud endpoint beside it its latches, and returns whether there was anything to forget. Call it
|
|
846
|
+
where the host already knows something changed: a settings row saved, a health check reading a new
|
|
847
|
+
build string, an operator pressing a button.
|
|
848
|
+
|
|
849
|
+
`expireCapabilities(maxAgeMs)` covers the case where nobody knows, dropping every endpoint older
|
|
850
|
+
than that and returning how many. An expiry does not probe anything — it stops suppressing, so the
|
|
851
|
+
next request carries the field again and a server that still refuses it refuses it once, which
|
|
852
|
+
`negotiate` answers as it always did. At an age measured in hours that is a few extra round trips a
|
|
853
|
+
day against a downgrade that would otherwise last as long as the process. Nothing calls it on a
|
|
854
|
+
timer; when to sweep is yours, the same way how stale a snapshot is too stale is.
|
|
855
|
+
|
|
856
|
+
An endpoint's age is when it was first met, not when a flag latched, and `exportCapabilities`
|
|
857
|
+
carries it in the snapshot so an imported latch keeps its real age instead of being born again on
|
|
858
|
+
every boot. Importing takes the older of the two ages, and a snapshot written before this field
|
|
859
|
+
existed reads as met now.
|
|
749
860
|
|
|
750
861
|
## Where the merged behaviour came from
|
|
751
862
|
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts
|
|
|
5
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
|
|
@@ -203,7 +213,8 @@ export interface AgentLoopResult {
|
|
|
203
213
|
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
204
214
|
*
|
|
205
215
|
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
206
|
-
* 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
|
|
207
218
|
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
208
219
|
* preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
|
|
209
220
|
* because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
package/dist/agent-loop.js
CHANGED
|
@@ -10,7 +10,7 @@ import { runTurn } from "./run-turn.js";
|
|
|
10
10
|
import { relaxTools, sanitizeTools } from "./schema-compat.js";
|
|
11
11
|
import { askJson, tryAsk } from "./side-task.js";
|
|
12
12
|
import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
|
|
13
|
-
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";
|
|
14
14
|
/**
|
|
15
15
|
* The loop above a turn: send, run the tools the model asked for, send again, until it stops
|
|
16
16
|
* asking.
|
|
@@ -40,11 +40,17 @@ const RESERVED = new Set(["model", "messages", "stream", "tools"]);
|
|
|
40
40
|
* @param refused What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
41
41
|
* that has refused nothing.
|
|
42
42
|
* @param messages The request's messages, system prompt included, sent as they are.
|
|
43
|
-
* @param tools The tool definitions.
|
|
44
|
-
* 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`.
|
|
45
48
|
*/
|
|
46
|
-
export function buildBody(config, supports, refused, messages, tools = []) {
|
|
47
|
-
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));
|
|
48
54
|
const effort = config.reasoningEffort;
|
|
49
55
|
const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED.has(field) && !refused?.refusedFields.has(field));
|
|
50
56
|
return {
|
|
@@ -183,7 +189,8 @@ function cacheDiagnosis(previous, messages, tools, usage) {
|
|
|
183
189
|
* whatever `runTurn` throws — `ContextOverflow` among them, however it was found out.
|
|
184
190
|
*
|
|
185
191
|
* On-demand loading is handled here, `load_tools` and all: the catalogue rides on the system
|
|
186
|
-
* 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
|
|
187
194
|
* a catalogued tool called without being loaded is loaded and run rather than refused, and a
|
|
188
195
|
* preselection shapes the first step. A turn cut off at `maxTokens` is said so as a notice,
|
|
189
196
|
* because it otherwise reads exactly like a finished one — or, given `maxContinuations`, is
|
|
@@ -195,7 +202,7 @@ function cacheDiagnosis(previous, messages, tools, usage) {
|
|
|
195
202
|
*/
|
|
196
203
|
export async function runAgentLoop(options) {
|
|
197
204
|
const { config, system = "", tools = [], catalog = [], dispatch, hooks, signal } = options;
|
|
198
|
-
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, } = options;
|
|
205
|
+
const { onTurn, beforeStep, parallel = false, recoverToolCalls: recover = true, maxContinuations = 0, toolOrder = true, } = options;
|
|
199
206
|
const started = Date.now();
|
|
200
207
|
// What the loop emitted, less the token deltas, for `runMetrics` at the end. Stamped here rather
|
|
201
208
|
// than by the bus, which the loop does not know about.
|
|
@@ -262,11 +269,14 @@ export async function runAgentLoop(options) {
|
|
|
262
269
|
messages = (await beforeStep?.(messages, step)) ?? messages;
|
|
263
270
|
onEvent({ kind: "turn", text: `turn ${step + 1}` });
|
|
264
271
|
const routed = preselected.length > 0 && step === 0;
|
|
265
|
-
|
|
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
|
|
266
276
|
? byName(new Set(preselected))
|
|
267
277
|
: onDemand
|
|
268
278
|
? loadedTools([LOAD_TOOLS_DEFINITION], byName(loaded))
|
|
269
|
-
: tools;
|
|
279
|
+
: tools, toolOrder);
|
|
270
280
|
// Unmarked, so the system prompt is the same text on every step and a load does not throw
|
|
271
281
|
// away the cache for the whole transcript. What is loaded is said in `declared` and in the
|
|
272
282
|
// `load_tools` result instead. The preselected first step is the one exception, by design.
|
|
@@ -275,7 +285,7 @@ export async function runAgentLoop(options) {
|
|
|
275
285
|
...(prompt ? [{ role: "system", content: prompt }] : []),
|
|
276
286
|
...withContext(messages, question ? messages.indexOf(question) : -1, gathered.context, hooks?.preface),
|
|
277
287
|
];
|
|
278
|
-
const build = (supported, refused) => buildBody(config, supported, refused, request, declared);
|
|
288
|
+
const build = (supported, refused) => buildBody(config, supported, refused, request, declared, toolOrder);
|
|
279
289
|
const turnOptions = {
|
|
280
290
|
model: config.model,
|
|
281
291
|
droppable: Object.keys(config.extraBody ?? {}),
|
|
@@ -427,7 +437,7 @@ export async function runAgentLoop(options) {
|
|
|
427
437
|
used.add(name);
|
|
428
438
|
const request = { id: call.id, name, args, raw };
|
|
429
439
|
content = parallel
|
|
430
|
-
? await once(answered, `${name}
|
|
440
|
+
? await once(answered, `${name}\0${normal}`, () => dispatch(request, signal))
|
|
431
441
|
: await dispatch(request, signal);
|
|
432
442
|
}
|
|
433
443
|
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface Capabilities {
|
|
|
35
35
|
* stopped. Empty until `modelCapabilitiesFor` is asked about a model.
|
|
36
36
|
*/
|
|
37
37
|
models: Map<string, ModelCapabilities>;
|
|
38
|
+
/**
|
|
39
|
+
* When this entry was opened, as epoch milliseconds: first contact with the endpoint in this
|
|
40
|
+
* process, or the age an imported snapshot gave it. What `expireCapabilities` measures.
|
|
41
|
+
*
|
|
42
|
+
* Not when a flag latched. A flag that latches at all almost always does so on the first
|
|
43
|
+
* request or two, and an entry that latched nothing has nothing to expire, so the difference
|
|
44
|
+
* costs at most one retried field somewhat earlier than it was due — which is the direction to
|
|
45
|
+
* be wrong in. Stamping each latch instead would mean stamping it in four modules and
|
|
46
|
+
* remembering to in the fifth.
|
|
47
|
+
*/
|
|
48
|
+
since: number;
|
|
38
49
|
}
|
|
39
50
|
/**
|
|
40
51
|
* What one model on that endpoint turned out not to support. All start optimistic and only ever
|
|
@@ -124,8 +135,41 @@ export declare const knownCapabilities: () => ReadonlyMap<string, Capabilities>;
|
|
|
124
135
|
* `importCapabilities` reaches an endpoint it has only a digest for.
|
|
125
136
|
*/
|
|
126
137
|
export declare function capabilitiesById(id: string): Capabilities;
|
|
127
|
-
/**
|
|
128
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Forgets what one endpoint refused, or every endpoint's when told none.
|
|
140
|
+
*
|
|
141
|
+
* A latch never unlatches on its own, so a server upgraded behind the same URL — a newer
|
|
142
|
+
* llama.cpp that compiles the grammar, a proxy that has learned `stream_options` — keeps being
|
|
143
|
+
* sent the downgraded request for the life of the process. This is the seam for a consumer that
|
|
144
|
+
* *knows* it changed: a settings row saved, a health check that reads a new build string, an
|
|
145
|
+
* operator pressing a button. See `expireCapabilities` for the case where nobody knows.
|
|
146
|
+
*
|
|
147
|
+
* @param endpoint Whose to forget, by the same identity `capabilitiesFor` takes. Absent clears
|
|
148
|
+
* every endpoint, which is what tests and `resetAll` mean by it.
|
|
149
|
+
* @returns Whether there was anything to forget.
|
|
150
|
+
*/
|
|
151
|
+
export declare function resetCapabilities(endpoint?: {
|
|
152
|
+
baseUrl: string;
|
|
153
|
+
apiKey?: string;
|
|
154
|
+
}): boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Forgets every endpoint whose entry is older than this, so the next request finds out again.
|
|
157
|
+
*
|
|
158
|
+
* The other half of the problem `resetCapabilities` solves: a server upgraded behind the same URL
|
|
159
|
+
* with nobody to notice. What an expiry costs is one round trip per endpoint and model — the next
|
|
160
|
+
* request carries the field again, and a server that still refuses it refuses it once and
|
|
161
|
+
* `negotiate` re-sends — so at an age measured in hours it is a few requests a day against a
|
|
162
|
+
* downgrade that would otherwise last as long as the process. That is the trade `exportCapabilities`
|
|
163
|
+
* exists to avoid paying *per restart*; paying it per day is a different bargain.
|
|
164
|
+
*
|
|
165
|
+
* Nothing calls this on a timer. When to sweep is the consumer's, the same way how stale a
|
|
166
|
+
* snapshot is too stale is, and a sweep costs a walk of one settings row's worth of entries.
|
|
167
|
+
*
|
|
168
|
+
* @param maxAgeMs How old an entry may be. Zero or less expires everything.
|
|
169
|
+
* @param now The clock, for tests.
|
|
170
|
+
* @returns How many endpoints were forgotten.
|
|
171
|
+
*/
|
|
172
|
+
export declare function expireCapabilities(maxAgeMs: number, now?: number): number;
|
|
129
173
|
/** What `negotiate` takes besides the request. All optional. */
|
|
130
174
|
export interface NegotiateOptions {
|
|
131
175
|
/**
|
package/dist/capabilities.js
CHANGED
|
@@ -71,14 +71,58 @@ export const knownCapabilities = () => capabilities;
|
|
|
71
71
|
export function capabilitiesById(id) {
|
|
72
72
|
let known = capabilities.get(id);
|
|
73
73
|
if (!known) {
|
|
74
|
-
known = { strictSchemas: true, usageInStream: true, models: new Map() };
|
|
74
|
+
known = { strictSchemas: true, usageInStream: true, models: new Map(), since: Date.now() };
|
|
75
75
|
capabilities.set(id, known);
|
|
76
76
|
}
|
|
77
77
|
return known;
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Forgets what one endpoint refused, or every endpoint's when told none.
|
|
81
|
+
*
|
|
82
|
+
* A latch never unlatches on its own, so a server upgraded behind the same URL — a newer
|
|
83
|
+
* llama.cpp that compiles the grammar, a proxy that has learned `stream_options` — keeps being
|
|
84
|
+
* sent the downgraded request for the life of the process. This is the seam for a consumer that
|
|
85
|
+
* *knows* it changed: a settings row saved, a health check that reads a new build string, an
|
|
86
|
+
* operator pressing a button. See `expireCapabilities` for the case where nobody knows.
|
|
87
|
+
*
|
|
88
|
+
* @param endpoint Whose to forget, by the same identity `capabilitiesFor` takes. Absent clears
|
|
89
|
+
* every endpoint, which is what tests and `resetAll` mean by it.
|
|
90
|
+
* @returns Whether there was anything to forget.
|
|
91
|
+
*/
|
|
92
|
+
export function resetCapabilities(endpoint) {
|
|
93
|
+
if (!endpoint) {
|
|
94
|
+
const held = capabilities.size > 0;
|
|
95
|
+
capabilities.clear();
|
|
96
|
+
return held;
|
|
97
|
+
}
|
|
98
|
+
return capabilities.delete(endpointId(endpoint));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Forgets every endpoint whose entry is older than this, so the next request finds out again.
|
|
102
|
+
*
|
|
103
|
+
* The other half of the problem `resetCapabilities` solves: a server upgraded behind the same URL
|
|
104
|
+
* with nobody to notice. What an expiry costs is one round trip per endpoint and model — the next
|
|
105
|
+
* request carries the field again, and a server that still refuses it refuses it once and
|
|
106
|
+
* `negotiate` re-sends — so at an age measured in hours it is a few requests a day against a
|
|
107
|
+
* downgrade that would otherwise last as long as the process. That is the trade `exportCapabilities`
|
|
108
|
+
* exists to avoid paying *per restart*; paying it per day is a different bargain.
|
|
109
|
+
*
|
|
110
|
+
* Nothing calls this on a timer. When to sweep is the consumer's, the same way how stale a
|
|
111
|
+
* snapshot is too stale is, and a sweep costs a walk of one settings row's worth of entries.
|
|
112
|
+
*
|
|
113
|
+
* @param maxAgeMs How old an entry may be. Zero or less expires everything.
|
|
114
|
+
* @param now The clock, for tests.
|
|
115
|
+
* @returns How many endpoints were forgotten.
|
|
116
|
+
*/
|
|
117
|
+
export function expireCapabilities(maxAgeMs, now = Date.now()) {
|
|
118
|
+
let dropped = 0;
|
|
119
|
+
for (const [id, known] of capabilities) {
|
|
120
|
+
if (now - known.since < maxAgeMs)
|
|
121
|
+
continue;
|
|
122
|
+
capabilities.delete(id);
|
|
123
|
+
dropped++;
|
|
124
|
+
}
|
|
125
|
+
return dropped;
|
|
82
126
|
}
|
|
83
127
|
/**
|
|
84
128
|
* Every latching flag in play on one attempt, endpoint and model together, in a stable order.
|
package/dist/compaction.d.ts
CHANGED
|
@@ -70,6 +70,16 @@ export interface CompactionOptions {
|
|
|
70
70
|
* an `estimate` of the caller's own.
|
|
71
71
|
*/
|
|
72
72
|
charsPerToken?: number;
|
|
73
|
+
/**
|
|
74
|
+
* The first message that may be folded. Absent, the leading `system` messages are skipped and
|
|
75
|
+
* the fold starts after them.
|
|
76
|
+
*/
|
|
77
|
+
from?: number;
|
|
78
|
+
/**
|
|
79
|
+
* The summary an earlier fold left, which this one continues. Absent, it is recovered from a
|
|
80
|
+
* `SUMMARY_LEAD` system message at the head, if there is one.
|
|
81
|
+
*/
|
|
82
|
+
previous?: string;
|
|
73
83
|
}
|
|
74
84
|
/** Where to cut, as `compactTranscript` takes it. */
|
|
75
85
|
export interface CompactionPlan {
|
|
@@ -92,10 +102,16 @@ export interface CompactionPlan {
|
|
|
92
102
|
* than summarised as if it were conversation. No plan comes back when the window is not full
|
|
93
103
|
* enough, or when the only legal cut folds too little to pay for the summary.
|
|
94
104
|
*
|
|
105
|
+
* Both of those are recovered by reading the transcript, which is what a host whose array holds
|
|
106
|
+
* everything it sends has to do. A host that keeps its fold as a record beside an append-only
|
|
107
|
+
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
108
|
+
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
109
|
+
*
|
|
95
110
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
96
|
-
* @param options The window, what is in use, and the
|
|
111
|
+
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
112
|
+
* `CompactionOptions`.
|
|
97
113
|
*/
|
|
98
|
-
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, }: CompactionOptions): CompactionPlan | undefined;
|
|
114
|
+
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
99
115
|
/**
|
|
100
116
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
101
117
|
* message as its role and at most 4000 characters of its text.
|
|
@@ -114,6 +130,93 @@ export declare function summaryInput(plan: CompactionPlan): string;
|
|
|
114
130
|
export declare const summariser: (config: Endpoint, model: string, { system, maxTokens, ...options }?: SideTaskOptions & {
|
|
115
131
|
system?: string;
|
|
116
132
|
}) => (text: string) => Promise<string>;
|
|
133
|
+
/**
|
|
134
|
+
* One fold, as a host that keeps its transcript append-only stores it.
|
|
135
|
+
*
|
|
136
|
+
* The other half of `compactTranscript`: the same work, recorded rather than applied. A host that
|
|
137
|
+
* persists this beside an untouched transcript still shows the user every message, can undo a fold
|
|
138
|
+
* by dropping one row, and rebuilds the request with `applyCompaction` — where a host that keeps
|
|
139
|
+
* only the rewritten array has thrown the originals away.
|
|
140
|
+
*/
|
|
141
|
+
export interface CompactionRecord {
|
|
142
|
+
/** The model's notes on everything before `through`. Trimmed, and never empty. */
|
|
143
|
+
summary: string;
|
|
144
|
+
/** Index into the transcript the plan was made for: the first message still sent whole. */
|
|
145
|
+
through: number;
|
|
146
|
+
/** ISO 8601, so the chat can show where history was folded and how stale the notes are. */
|
|
147
|
+
at: string;
|
|
148
|
+
}
|
|
149
|
+
/** What `runCompaction` and `compactTranscript` take beside the plan. */
|
|
150
|
+
export interface CompactionRunOptions {
|
|
151
|
+
/**
|
|
152
|
+
* Hooks to tell. `context` is extended with `compacting` and `range`, whose indexes are the
|
|
153
|
+
* plan's — and so the host's own, for a plan made over a stored transcript. `honourVeto` waits
|
|
154
|
+
* for the hooks and lets one stop the compaction; off by default, which adds no latency.
|
|
155
|
+
*/
|
|
156
|
+
hooks?: {
|
|
157
|
+
run: HookRunner;
|
|
158
|
+
context: HookContext;
|
|
159
|
+
onNote?: (note: HookNote) => void;
|
|
160
|
+
honourVeto?: boolean;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* The window is already exceeded — the caller caught a `ContextOverflow`, or is compacting to
|
|
164
|
+
* make a refused request fit — which overrides `honourVeto`.
|
|
165
|
+
*/
|
|
166
|
+
forced?: boolean;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
|
|
170
|
+
*
|
|
171
|
+
* What `compactTranscript` does before it rewrites anything, which is all a host needs when the
|
|
172
|
+
* fold lives on the session row and the messages stay where they are. Nothing here is persisted or
|
|
173
|
+
* logged — that is the host's, and so is deciding what to do with a fold that did not happen.
|
|
174
|
+
*
|
|
175
|
+
* @param messages The transcript the plan was made for. Read only, and only for the hooks.
|
|
176
|
+
* @param plan What `planCompaction` returned for it.
|
|
177
|
+
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
|
|
178
|
+
* when a hook vetoes.
|
|
179
|
+
* @param options Hooks to tell and whether the window is already past. See `CompactionRunOptions`.
|
|
180
|
+
* @returns `undefined` when nothing was folded — a hook vetoed, or the summary came back empty —
|
|
181
|
+
* so the caller stores nothing and the transcript is still whole.
|
|
182
|
+
*/
|
|
183
|
+
export declare function runCompaction(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, { hooks, forced }?: CompactionRunOptions): Promise<CompactionRecord | undefined>;
|
|
184
|
+
/**
|
|
185
|
+
* The transcript as the server should see it: the folded head replaced by its summary.
|
|
186
|
+
*
|
|
187
|
+
* The inverse of storing a `CompactionRecord`, and the shape `planCompaction` expects to meet
|
|
188
|
+
* again — the same `SUMMARY_LEAD`, in a `system` message at the same place — so the next fold
|
|
189
|
+
* continues these notes rather than summarising them a second time. Any earlier summary message in
|
|
190
|
+
* the kept head is dropped, since the record's already contains it.
|
|
191
|
+
*
|
|
192
|
+
* @param messages The stored transcript, whole. Not written to.
|
|
193
|
+
* @param record The fold, or `undefined` for a session that has not been compacted, which hands
|
|
194
|
+
* back `messages` itself.
|
|
195
|
+
* @param options `from` is the first message the fold was allowed to take — the plan's, for a host
|
|
196
|
+
* that keeps its system prompts in the array; everything before it is kept ahead of the summary.
|
|
197
|
+
* Absent, the leading `system` messages are found by scanning, and zero of them is the ordinary
|
|
198
|
+
* case for a host whose system prompt is a separate argument.
|
|
199
|
+
*/
|
|
200
|
+
export declare function applyCompaction(messages: Message[], record?: Pick<CompactionRecord, "summary" | "through">, { from }?: {
|
|
201
|
+
from?: number;
|
|
202
|
+
}): Message[];
|
|
203
|
+
/**
|
|
204
|
+
* Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted
|
|
205
|
+
* everything after it.
|
|
206
|
+
*
|
|
207
|
+
* A transcript that stays append-only and a request that does not are two numberings of the same
|
|
208
|
+
* conversation, and anything that names a position — `withContext`'s index, a range handed to a
|
|
209
|
+
* hook — has to say which it is in. An index inside the folded stretch answers with the summary
|
|
210
|
+
* message that now stands for it.
|
|
211
|
+
*
|
|
212
|
+
* @param index The position in the stored transcript.
|
|
213
|
+
* @param record The fold in force, or `undefined` for a session that has none, which hands the
|
|
214
|
+
* index straight back.
|
|
215
|
+
* @param head How many messages the request keeps ahead of the summary — the leading system
|
|
216
|
+
* prompts, when the host keeps them in the array. Zero, the default, is the stored-fold case,
|
|
217
|
+
* where the summary is the request's first message.
|
|
218
|
+
*/
|
|
219
|
+
export declare const requestIndex: (index: number, record?: Pick<CompactionRecord, "through">, head?: number) => number;
|
|
117
220
|
/**
|
|
118
221
|
* The transcript with the plan's stretch replaced by one system message holding its summary.
|
|
119
222
|
*
|
|
@@ -127,25 +230,18 @@ export declare const summariser: (config: Endpoint, model: string, { system, max
|
|
|
127
230
|
* `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
|
|
128
231
|
* comment on when to run it.
|
|
129
232
|
*
|
|
233
|
+
* `runCompaction` and `applyCompaction` are its two halves, and it is nothing but the two in
|
|
234
|
+
* order, so a host that stores the fold instead of the array gets the same summary at the same
|
|
235
|
+
* cut rather than a second implementation that drifts from this one.
|
|
236
|
+
*
|
|
130
237
|
* @param messages The transcript the plan was made for. Not written to.
|
|
131
238
|
* @param plan What `planCompaction` returned for it.
|
|
132
239
|
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
|
|
133
240
|
* when a hook vetoes.
|
|
134
|
-
* @param options Hooks to tell
|
|
135
|
-
*
|
|
136
|
-
* by default, which adds no latency. `forced` says the window is already exceeded — the caller
|
|
137
|
-
* caught a `ContextOverflow`, or is compacting to make a refused request fit — and overrides
|
|
138
|
-
* `honourVeto`.
|
|
241
|
+
* @param options Hooks to tell and whether the window is already past. See
|
|
242
|
+
* `CompactionRunOptions`.
|
|
139
243
|
* @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
|
|
140
244
|
* new array.
|
|
141
245
|
*/
|
|
142
|
-
export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>,
|
|
143
|
-
hooks?: {
|
|
144
|
-
run: HookRunner;
|
|
145
|
-
context: HookContext;
|
|
146
|
-
onNote?: (note: HookNote) => void;
|
|
147
|
-
honourVeto?: boolean;
|
|
148
|
-
};
|
|
149
|
-
forced?: boolean;
|
|
150
|
-
}): Promise<Message[]>;
|
|
246
|
+
export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, options?: CompactionRunOptions): Promise<Message[]>;
|
|
151
247
|
export {};
|