@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.
@@ -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
+ }
@@ -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`
@@ -56,6 +56,7 @@ export function modelCapabilitiesFor(supports, model) {
56
56
  chosenTemperature: true,
57
57
  refusedFields: new Set(),
58
58
  structuredOutput: true,
59
+ assistantPrefill: true,
59
60
  };
60
61
  supports.models.set(model, known);
61
62
  }
@@ -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 ratios. See `CompactionOptions`.
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 hook cannot veto the compaction: `HookOutcome` has no way to say so,
117
- * and a run over its window has no better option anyway. An empty summary folds nothing, and the
118
- * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
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
- * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
124
- * indexes are the plan's.
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>, { hooks, }?: {
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 {};
@@ -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 ratios. See `CompactionOptions`.
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
- let from = 0;
93
- let previous;
94
- while (from < messages.length && messages[from].role === "system") {
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 hook cannot veto the compaction: `HookOutcome` has no way to say so,
147
- * and a run over its window has no better option anyway. An empty summary folds nothing, and the
148
- * transcript comes back as it was. Rewrites the prefix; see the module comment on when to run it.
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
- * @param options Hooks to tell. `context` is extended with `compacting` and `range`, whose
154
- * indexes are the plan's.
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, { hooks, } = {}) {
157
- const [summary] = await Promise.all([
158
- summarise(summaryInput(plan)),
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>;