@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
|
@@ -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`
|
package/dist/capabilities.js
CHANGED
package/dist/compaction.d.ts
CHANGED
|
@@ -62,8 +62,24 @@ export interface CompactionOptions {
|
|
|
62
62
|
compactAt?: number;
|
|
63
63
|
/** The fraction of `limit` the kept tail may fill. `KEEP_RATIO` by default. */
|
|
64
64
|
keepRatio?: number;
|
|
65
|
-
/** One message's tokens. `messageTokens` by default
|
|
65
|
+
/** One message's tokens. `messageTokens` by default, divided by `charsPerToken`. */
|
|
66
66
|
estimate?: (message: Message) => number;
|
|
67
|
+
/**
|
|
68
|
+
* The divisor the default `estimate` uses — `charsPerTokenFor` the model, for a transcript
|
|
69
|
+
* weighed the way `runTurn` sizes its requests. `CHARS_PER_TOKEN` when absent; ignored beside
|
|
70
|
+
* an `estimate` of the caller's own.
|
|
71
|
+
*/
|
|
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;
|
|
67
83
|
}
|
|
68
84
|
/** Where to cut, as `compactTranscript` takes it. */
|
|
69
85
|
export interface CompactionPlan {
|
|
@@ -86,10 +102,16 @@ export interface CompactionPlan {
|
|
|
86
102
|
* than summarised as if it were conversation. No plan comes back when the window is not full
|
|
87
103
|
* enough, or when the only legal cut folds too little to pay for the summary.
|
|
88
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
|
+
*
|
|
89
110
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
90
|
-
* @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`.
|
|
91
113
|
*/
|
|
92
|
-
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, estimate, }: CompactionOptions): CompactionPlan | undefined;
|
|
114
|
+
export declare function planCompaction(messages: Message[], { limit, used, compactAt, keepRatio, charsPerToken, estimate, from: givenFrom, previous: givenPrevious, }: CompactionOptions): CompactionPlan | undefined;
|
|
93
115
|
/**
|
|
94
116
|
* What the summariser is handed for a plan: the earlier summary if there was one, then each
|
|
95
117
|
* message as its role and at most 4000 characters of its text.
|
|
@@ -108,26 +130,118 @@ export declare function summaryInput(plan: CompactionPlan): string;
|
|
|
108
130
|
export declare const summariser: (config: Endpoint, model: string, { system, maxTokens, ...options }?: SideTaskOptions & {
|
|
109
131
|
system?: string;
|
|
110
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;
|
|
111
220
|
/**
|
|
112
221
|
* The transcript with the plan's stretch replaced by one system message holding its summary.
|
|
113
222
|
*
|
|
114
223
|
* `beforeCompact` is told what is being folded while the summary is written, beside it rather
|
|
115
224
|
* than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
|
|
116
|
-
* `notify` never rejects. A
|
|
117
|
-
* and
|
|
118
|
-
*
|
|
225
|
+
* `notify` never rejects. A host that wants its hooks able to stop a compaction sets
|
|
226
|
+
* `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
|
|
227
|
+
* `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
|
|
228
|
+
* compaction ignores a veto and runs the hooks beside the summary as before, because a run already
|
|
229
|
+
* past its window has no better option — a veto there only trades the summary for a
|
|
230
|
+
* `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
|
|
231
|
+
* comment on when to run it.
|
|
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.
|
|
119
236
|
*
|
|
120
237
|
* @param messages The transcript the plan was made for. Not written to.
|
|
121
238
|
* @param plan What `planCompaction` returned for it.
|
|
122
|
-
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
|
|
123
|
-
*
|
|
124
|
-
*
|
|
239
|
+
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
|
|
240
|
+
* when a hook vetoes.
|
|
241
|
+
* @param options Hooks to tell and whether the window is already past. See
|
|
242
|
+
* `CompactionRunOptions`.
|
|
243
|
+
* @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
|
|
244
|
+
* new array.
|
|
125
245
|
*/
|
|
126
|
-
export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>,
|
|
127
|
-
hooks?: {
|
|
128
|
-
run: HookRunner;
|
|
129
|
-
context: HookContext;
|
|
130
|
-
onNote?: (note: HookNote) => void;
|
|
131
|
-
};
|
|
132
|
-
}): Promise<Message[]>;
|
|
246
|
+
export declare function compactTranscript(messages: Message[], plan: CompactionPlan, summarise: (text: string) => Promise<string>, options?: CompactionRunOptions): Promise<Message[]>;
|
|
133
247
|
export {};
|
package/dist/compaction.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { notify, turnMessages } from "./hooks.js";
|
|
1
|
+
import { consult, notify, turnMessages, } from "./hooks.js";
|
|
2
2
|
import { messageTokens } from "./retry.js";
|
|
3
3
|
import { ask } from "./side-task.js";
|
|
4
4
|
/** The fraction of the window in use before a summary is worth its own round trip. */
|
|
@@ -37,6 +37,25 @@ const messageText = (message) => {
|
|
|
37
37
|
return `${textOf(message.content)} ${calls}`.trim();
|
|
38
38
|
};
|
|
39
39
|
const isSummary = (message) => message.role === "system" && textOf(message.content).startsWith(SUMMARY_LEAD);
|
|
40
|
+
/** The summary as it sits in a transcript, which is the one shape `isSummary` recognises again. */
|
|
41
|
+
const summaryMessage = (summary) => ({
|
|
42
|
+
role: "system",
|
|
43
|
+
content: `${SUMMARY_LEAD}${summary.trim()}`,
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* The leading system messages a fold never touches, and the summary an earlier one left among
|
|
47
|
+
* them. What a caller who keeps its system prompt out of the array knows already, and passes.
|
|
48
|
+
*/
|
|
49
|
+
const systemHead = (messages) => {
|
|
50
|
+
let from = 0;
|
|
51
|
+
let previous;
|
|
52
|
+
while (from < messages.length && messages[from].role === "system") {
|
|
53
|
+
if (isSummary(messages[from]))
|
|
54
|
+
previous = textOf(messages[from].content).slice(SUMMARY_LEAD.length);
|
|
55
|
+
from++;
|
|
56
|
+
}
|
|
57
|
+
return { from, previous };
|
|
58
|
+
};
|
|
40
59
|
/**
|
|
41
60
|
* The transcript with every tool result but the latest few replaced by a one-line stub.
|
|
42
61
|
*
|
|
@@ -80,22 +99,24 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
|
|
|
80
99
|
* than summarised as if it were conversation. No plan comes back when the window is not full
|
|
81
100
|
* enough, or when the only legal cut folds too little to pay for the summary.
|
|
82
101
|
*
|
|
102
|
+
* Both of those are recovered by reading the transcript, which is what a host whose array holds
|
|
103
|
+
* everything it sends has to do. A host that keeps its fold as a record beside an append-only
|
|
104
|
+
* transcript — its system prompt a separate argument, no summary message in the array at all —
|
|
105
|
+
* knows them exactly, and passes `from` and `previous` instead of hoping the scan agrees.
|
|
106
|
+
*
|
|
83
107
|
* @param messages The transcript, system prompts included if the caller keeps them in it.
|
|
84
|
-
* @param options The window, what is in use, and the
|
|
108
|
+
* @param options The window, what is in use, the ratios, and where the last fold ended. See
|
|
109
|
+
* `CompactionOptions`.
|
|
85
110
|
*/
|
|
86
|
-
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, estimate = messageTokens, }) {
|
|
111
|
+
export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT, keepRatio = KEEP_RATIO, charsPerToken, estimate = (message) => messageTokens(message, { charsPerToken }), from: givenFrom, previous: givenPrevious, }) {
|
|
87
112
|
if (!(limit > 0))
|
|
88
113
|
return undefined;
|
|
89
114
|
const cost = used ?? messages.reduce((total, message) => total + estimate(message), 0);
|
|
90
115
|
if (cost < limit * compactAt)
|
|
91
116
|
return undefined;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (isSummary(messages[from]))
|
|
96
|
-
previous = textOf(messages[from].content).slice(SUMMARY_LEAD.length);
|
|
97
|
-
from++;
|
|
98
|
-
}
|
|
117
|
+
const head = systemHead(messages);
|
|
118
|
+
const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
|
|
119
|
+
const previous = givenPrevious ?? head.previous;
|
|
99
120
|
const budget = limit * keepRatio;
|
|
100
121
|
let kept = 0;
|
|
101
122
|
let cut = messages.length;
|
|
@@ -138,36 +159,120 @@ export function summaryInput(plan) {
|
|
|
138
159
|
* `SUMMARY_PROMPT` unless given.
|
|
139
160
|
*/
|
|
140
161
|
export const summariser = (config, model, { system = SUMMARY_PROMPT, maxTokens = 1024, ...options } = {}) => (text) => ask(config, model, system, text, { maxTokens, ...options });
|
|
162
|
+
/**
|
|
163
|
+
* The hooks and the summariser for a plan, as a record to store rather than a transcript to send.
|
|
164
|
+
*
|
|
165
|
+
* What `compactTranscript` does before it rewrites anything, which is all a host needs when the
|
|
166
|
+
* fold lives on the session row and the messages stay where they are. Nothing here is persisted or
|
|
167
|
+
* logged — that is the host's, and so is deciding what to do with a fold that did not happen.
|
|
168
|
+
*
|
|
169
|
+
* @param messages The transcript the plan was made for. Read only, and only for the hooks.
|
|
170
|
+
* @param plan What `planCompaction` returned for it.
|
|
171
|
+
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
|
|
172
|
+
* when a hook vetoes.
|
|
173
|
+
* @param options Hooks to tell and whether the window is already past. See `CompactionRunOptions`.
|
|
174
|
+
* @returns `undefined` when nothing was folded — a hook vetoed, or the summary came back empty —
|
|
175
|
+
* so the caller stores nothing and the transcript is still whole.
|
|
176
|
+
*/
|
|
177
|
+
export async function runCompaction(messages, plan, summarise, { hooks, forced = false } = {}) {
|
|
178
|
+
const context = hooks && {
|
|
179
|
+
...hooks.context,
|
|
180
|
+
compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
|
|
181
|
+
range: { from: plan.from, through: plan.cut },
|
|
182
|
+
};
|
|
183
|
+
let summary;
|
|
184
|
+
if (hooks && context && hooks.honourVeto && !forced) {
|
|
185
|
+
const { vetoed } = await consult(hooks.run, "beforeCompact", context, hooks.onNote);
|
|
186
|
+
if (vetoed)
|
|
187
|
+
return undefined;
|
|
188
|
+
summary = await summarise(summaryInput(plan));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
[summary] = await Promise.all([
|
|
192
|
+
summarise(summaryInput(plan)),
|
|
193
|
+
hooks && context && notify(hooks.run, "beforeCompact", context, hooks.onNote),
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
if (!summary.trim())
|
|
197
|
+
return undefined;
|
|
198
|
+
return { summary: summary.trim(), through: plan.cut, at: new Date().toISOString() };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The transcript as the server should see it: the folded head replaced by its summary.
|
|
202
|
+
*
|
|
203
|
+
* The inverse of storing a `CompactionRecord`, and the shape `planCompaction` expects to meet
|
|
204
|
+
* again — the same `SUMMARY_LEAD`, in a `system` message at the same place — so the next fold
|
|
205
|
+
* continues these notes rather than summarising them a second time. Any earlier summary message in
|
|
206
|
+
* the kept head is dropped, since the record's already contains it.
|
|
207
|
+
*
|
|
208
|
+
* @param messages The stored transcript, whole. Not written to.
|
|
209
|
+
* @param record The fold, or `undefined` for a session that has not been compacted, which hands
|
|
210
|
+
* back `messages` itself.
|
|
211
|
+
* @param options `from` is the first message the fold was allowed to take — the plan's, for a host
|
|
212
|
+
* that keeps its system prompts in the array; everything before it is kept ahead of the summary.
|
|
213
|
+
* Absent, the leading `system` messages are found by scanning, and zero of them is the ordinary
|
|
214
|
+
* case for a host whose system prompt is a separate argument.
|
|
215
|
+
*/
|
|
216
|
+
export function applyCompaction(messages, record, { from } = {}) {
|
|
217
|
+
if (!record?.summary.trim())
|
|
218
|
+
return messages;
|
|
219
|
+
const head = Math.min(Math.max(from ?? systemHead(messages).from, 0), record.through);
|
|
220
|
+
return [
|
|
221
|
+
...messages.slice(0, head).filter((message) => !isSummary(message)),
|
|
222
|
+
summaryMessage(record.summary),
|
|
223
|
+
...messages.slice(record.through),
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Where a stored index sits in the request `applyCompaction` builds, once a fold has shifted
|
|
228
|
+
* everything after it.
|
|
229
|
+
*
|
|
230
|
+
* A transcript that stays append-only and a request that does not are two numberings of the same
|
|
231
|
+
* conversation, and anything that names a position — `withContext`'s index, a range handed to a
|
|
232
|
+
* hook — has to say which it is in. An index inside the folded stretch answers with the summary
|
|
233
|
+
* message that now stands for it.
|
|
234
|
+
*
|
|
235
|
+
* @param index The position in the stored transcript.
|
|
236
|
+
* @param record The fold in force, or `undefined` for a session that has none, which hands the
|
|
237
|
+
* index straight back.
|
|
238
|
+
* @param head How many messages the request keeps ahead of the summary — the leading system
|
|
239
|
+
* prompts, when the host keeps them in the array. Zero, the default, is the stored-fold case,
|
|
240
|
+
* where the summary is the request's first message.
|
|
241
|
+
*/
|
|
242
|
+
export const requestIndex = (index, record, head = 0) => {
|
|
243
|
+
if (!record)
|
|
244
|
+
return index;
|
|
245
|
+
return index < record.through ? head : index - record.through + head + 1;
|
|
246
|
+
};
|
|
141
247
|
/**
|
|
142
248
|
* The transcript with the plan's stretch replaced by one system message holding its summary.
|
|
143
249
|
*
|
|
144
250
|
* `beforeCompact` is told what is being folded while the summary is written, beside it rather
|
|
145
251
|
* than ahead of it — a memory server filing it is not a rescue worth making the run wait for, and
|
|
146
|
-
* `notify` never rejects. A
|
|
147
|
-
* and
|
|
148
|
-
*
|
|
252
|
+
* `notify` never rejects. A host that wants its hooks able to stop a compaction sets
|
|
253
|
+
* `honourVeto`, and then they run first and the summary waits on them: any `ok` outcome carrying
|
|
254
|
+
* `veto` leaves the transcript as it was, and each vetoing hook is noted by name. A `forced`
|
|
255
|
+
* compaction ignores a veto and runs the hooks beside the summary as before, because a run already
|
|
256
|
+
* past its window has no better option — a veto there only trades the summary for a
|
|
257
|
+
* `ContextOverflow`. An empty summary folds nothing either. Rewrites the prefix; see the module
|
|
258
|
+
* comment on when to run it.
|
|
259
|
+
*
|
|
260
|
+
* `runCompaction` and `applyCompaction` are its two halves, and it is nothing but the two in
|
|
261
|
+
* order, so a host that stores the fold instead of the array gets the same summary at the same
|
|
262
|
+
* cut rather than a second implementation that drifts from this one.
|
|
149
263
|
*
|
|
150
264
|
* @param messages The transcript the plan was made for. Not written to.
|
|
151
265
|
* @param plan What `planCompaction` returned for it.
|
|
152
|
-
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`.
|
|
153
|
-
*
|
|
154
|
-
*
|
|
266
|
+
* @param summarise Writes the summary from `summaryInput`'s text. See `summariser`. Not called
|
|
267
|
+
* when a hook vetoes.
|
|
268
|
+
* @param options Hooks to tell and whether the window is already past. See
|
|
269
|
+
* `CompactionRunOptions`.
|
|
270
|
+
* @returns `messages` itself when nothing was folded — a veto or an empty summary — otherwise a
|
|
271
|
+
* new array.
|
|
155
272
|
*/
|
|
156
|
-
export async function compactTranscript(messages, plan, summarise,
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
hooks &&
|
|
160
|
-
notify(hooks.run, "beforeCompact", {
|
|
161
|
-
...hooks.context,
|
|
162
|
-
compacting: turnMessages(hooks.context.session.id, messages, plan.from, plan.cut),
|
|
163
|
-
range: { from: plan.from, through: plan.cut },
|
|
164
|
-
}, hooks.onNote),
|
|
165
|
-
]);
|
|
166
|
-
if (!summary.trim())
|
|
273
|
+
export async function compactTranscript(messages, plan, summarise, options = {}) {
|
|
274
|
+
const record = await runCompaction(messages, plan, summarise, options);
|
|
275
|
+
if (!record)
|
|
167
276
|
return messages;
|
|
168
|
-
return
|
|
169
|
-
...messages.slice(0, plan.from).filter((message) => !isSummary(message)),
|
|
170
|
-
{ role: "system", content: `${SUMMARY_LEAD}${summary.trim()}` },
|
|
171
|
-
...messages.slice(plan.cut),
|
|
172
|
-
];
|
|
277
|
+
return applyCompaction(messages, record, { from: plan.from });
|
|
173
278
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
|
|
3
|
+
import { type RunTurnOptions } from "./run-turn.ts";
|
|
4
|
+
import type { Turn } from "./stream.ts";
|
|
5
|
+
/**
|
|
6
|
+
* Picking up an answer the token ceiling cut off, instead of keeping half of it.
|
|
7
|
+
*
|
|
8
|
+
* A turn that stops at `maxTokens` mid-answer comes back looking finished, and the half answer
|
|
9
|
+
* becomes the answer. A server that renders a trailing assistant message as a prefill lets the
|
|
10
|
+
* model carry on from the last token as if nothing had happened, which costs the rest of the
|
|
11
|
+
* reply and a prefill the cache mostly already holds.
|
|
12
|
+
*/
|
|
13
|
+
/** What `continueTurn` takes besides what `runTurn` does. */
|
|
14
|
+
export interface ContinueTurnOptions extends RunTurnOptions {
|
|
15
|
+
/**
|
|
16
|
+
* How many more requests one answer may be given, 1 unless given; zero continues nothing.
|
|
17
|
+
* The cap is what stops a model that never reaches a stop token from looping on the ceiling.
|
|
18
|
+
*/
|
|
19
|
+
maxContinuations?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a turn is one a continuation can finish: cut off at the ceiling, with an answer begun
|
|
23
|
+
* and no tool call in it.
|
|
24
|
+
*
|
|
25
|
+
* An answer not begun is a turn cut off in its scratchpad, and prefilling a half-closed fence is
|
|
26
|
+
* the template's business rather than something this can do the same way everywhere — llama.cpp
|
|
27
|
+
* refuses a prefill outright on a template with thinking enabled. A tool call is excluded because
|
|
28
|
+
* its arguments are what was cut, and `parseToolArguments` already reports that truncation.
|
|
29
|
+
*
|
|
30
|
+
* @param turn The turn as it came back.
|
|
31
|
+
*/
|
|
32
|
+
export declare const isContinuable: (turn: Turn) => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
|
|
35
|
+
* so far as a trailing assistant message, and joins the pieces into one turn.
|
|
36
|
+
*
|
|
37
|
+
* Only a turn `isContinuable` accepts is continued; any other comes back as it was. Content and
|
|
38
|
+
* reasoning are joined in order, the tool calls a continuation makes are kept, and usage is summed
|
|
39
|
+
* across the requests with `continuations` counting them. The continuation is read as starting in
|
|
40
|
+
* the answer, whatever `startInReasoning` says: a template that opens a fence for a fresh reply
|
|
41
|
+
* does not open one for a prefill. Its tokens reach `onOutput` as they arrive, so a watcher sees
|
|
42
|
+
* one answer carry on rather than two.
|
|
43
|
+
*
|
|
44
|
+
* Whether the server continues at all is latched per model as `assistantPrefill`. A refusal of the
|
|
45
|
+
* request latches it off, and so does a continuation that begins the answer again, which is how a
|
|
46
|
+
* server that takes the request and ignores the prefill — hosted OpenAI among them — shows itself;
|
|
47
|
+
* that check is only as good as a restart being word for word. Either way the answer so far is
|
|
48
|
+
* kept, with a notice. So is it when the continuation fails any other way, since the tokens
|
|
49
|
+
* already in hand are worth more than the error; only a stop is thrown.
|
|
50
|
+
*
|
|
51
|
+
* @param client The pooled client for this endpoint.
|
|
52
|
+
* @param supports What the endpoint has already refused.
|
|
53
|
+
* @param request Builds the body the cut-off turn was sent, exactly as `runTurn` was given it. The
|
|
54
|
+
* prefill is appended to what it builds.
|
|
55
|
+
* @param turn The turn that came back cut off.
|
|
56
|
+
* @param options `runTurn`'s options, with `model` needed for the latch — without one nothing is
|
|
57
|
+
* latched and each continuation finds out again — and the cap on continuations.
|
|
58
|
+
*/
|
|
59
|
+
export declare function continueTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
|