@cubicecho/agent-core 2.2.4 → 2.4.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/dist/hooks.js ADDED
@@ -0,0 +1,256 @@
1
+ import { createHash } from "node:crypto";
2
+ import { errorMessage } from "./errors.js";
3
+ import { estimateTokens } from "./tokens.js";
4
+ /** Every event a hook can be bound to, in the order a session meets them. */
5
+ export const HOOK_EVENTS = [
6
+ "sessionStart",
7
+ "beforeTurn",
8
+ "afterTurn",
9
+ "beforeCompact",
10
+ "sessionEnd",
11
+ "sessionDelete",
12
+ ];
13
+ /**
14
+ * The events whose hooks run before a request, and so the only ones whose output can reach it.
15
+ * Anything later runs once the model has already answered.
16
+ */
17
+ export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);
18
+ /**
19
+ * The most context all of a request's hooks add between them by default, in estimated tokens.
20
+ *
21
+ * Enough for a handful of recalled memories, and small against any window worth running an agent
22
+ * in. The point is that a generous hook cannot crowd out the conversation it was meant to inform.
23
+ * `configureHooks` moves it for a process, and `gather` and `assembleContext` for one request.
24
+ */
25
+ export const HOOK_CONTEXT_TOKENS = 2000;
26
+ /** The numbers this module was written with. */
27
+ const HOOK_DEFAULTS = { contextTokens: HOOK_CONTEXT_TOKENS };
28
+ /** What is in force now. Read where it is used, so a change applies from the next request. */
29
+ let hookLimits = { ...HOOK_DEFAULTS };
30
+ /**
31
+ * Changes what hooks are held to, for a process whose windows are not the size these defaults
32
+ * were chosen for.
33
+ *
34
+ * Module-level for the same reason `configureEvents` is: a budget is a deployment's setting, said
35
+ * once at startup. A caller that sizes it per model or per agent — a 128k window can afford more
36
+ * recall than an 8k one — passes `maxTokens` to `gather` instead, which wins over this.
37
+ *
38
+ * @param options The limits to change. A field left out — or given anything that is not a number
39
+ * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
40
+ * above zero, and lifts the shared budget entirely.
41
+ * @returns Everything in force afterwards, including what this call did not change.
42
+ */
43
+ export function configureHooks(options = {}) {
44
+ for (const [name, value] of Object.entries(options)) {
45
+ if (typeof value === "number" && value > 0)
46
+ hookLimits[name] = value;
47
+ }
48
+ return { ...hookLimits };
49
+ }
50
+ /**
51
+ * Test seam: puts `configureHooks` back to the defaults, so one test's budget is not the next's.
52
+ * `resetAll` calls it.
53
+ */
54
+ export const resetHooks = () => {
55
+ hookLimits = { ...HOOK_DEFAULTS };
56
+ };
57
+ /**
58
+ * The budget a call is held to: its own when it gave a usable one, the process's otherwise. The
59
+ * same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
60
+ * turn every hook's context off.
61
+ */
62
+ const budget = (given) => typeof given === "number" && given > 0 ? given : hookLimits.contextTokens;
63
+ /**
64
+ * Said once, above the blocks, so the model reads them as background rather than instructions.
65
+ * Names no host; `withContext` takes another for one that wants to.
66
+ */
67
+ export const HOOK_PREFACE = "The <context> blocks below were added for this message by the host's hooks. They are " +
68
+ "background the user did not write and may not be relevant. The user's message follows them.";
69
+ const attribute = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
70
+ /**
71
+ * Builds the context a set of outcomes adds and the notes that go with it.
72
+ *
73
+ * Each injected outcome is wrapped in `<context source="…">` naming its label, so a model reading
74
+ * a recalled line can tell it is a memory rather than something the user said. Each is held to
75
+ * its own `maxTokens` and the whole to `maxTokens` here, and a block past the total is dropped
76
+ * whole rather than cut to a stub. For any hooks the pool's `validateHooks` accepts, the text is
77
+ * what its `contextBlocks` builds from the same outcomes, character for character, so a host
78
+ * moving between the two sends the same request.
79
+ *
80
+ * The note keeps each hook's text as it was cut, so a host can show exactly what the model was
81
+ * given without re-deriving the caps or parsing the wrapper back off.
82
+ *
83
+ * @param outcomes What the runners returned. An injecting outcome on an event that cannot inject
84
+ * adds nothing; a failed one is noted wherever it falls, including past the budget.
85
+ * @param maxTokens The budget every block shares. Absent, or not a number above zero, is what
86
+ * `configureHooks` last set — `HOOK_CONTEXT_TOKENS` unless something moved it.
87
+ */
88
+ export function assembleContext(outcomes, maxTokens) {
89
+ const blocks = [];
90
+ const notes = [];
91
+ let remaining = budget(maxTokens);
92
+ for (const outcome of outcomes) {
93
+ const base = { event: outcome.event, source: outcome.label, hookId: outcome.hookId };
94
+ if (!outcome.ok) {
95
+ notes.push({ ...base, error: outcome.error ?? "failed" });
96
+ continue;
97
+ }
98
+ if (!outcome.inject || !INJECT_EVENTS.has(outcome.event))
99
+ continue;
100
+ let text = outcome.text?.trim();
101
+ if (!text)
102
+ continue;
103
+ const cap = Math.min(outcome.maxTokens, remaining);
104
+ if (cap <= 0)
105
+ continue;
106
+ if (estimateTokens(text) > cap)
107
+ text = `${text.slice(0, cap * 4 - 1).trimEnd()}…`;
108
+ const tokens = estimateTokens(text);
109
+ remaining -= tokens;
110
+ blocks.push(`<context source="${attribute(outcome.label)}">\n${text}\n</context>`);
111
+ notes.push({ ...base, tokens, text });
112
+ }
113
+ return { context: blocks.join("\n\n"), notes };
114
+ }
115
+ /**
116
+ * The request, with the hooks' context added to this turn's question.
117
+ *
118
+ * It goes on the question and not in the system prompt, because it is about the question — and a
119
+ * system prompt that changed every turn would miss the prompt cache every turn. Nothing is written
120
+ * back: a host that stores what the user typed never remembers the context as something they said.
121
+ *
122
+ * @param history The request's messages. Neither the array nor any message in it is changed.
123
+ * @param index Where this turn's question sits in `history` — which is not where it sits in the
124
+ * session once a compaction has folded the head into a summary. Anything but a user message there
125
+ * leaves the request as it was.
126
+ * @param context What `assembleContext` built. Empty returns `history` itself.
127
+ * @param preface Said above the blocks. Defaults to `HOOK_PREFACE`.
128
+ * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
129
+ */
130
+ export function withContext(history, index, context, preface = HOOK_PREFACE) {
131
+ const message = history[index];
132
+ if (!context || message?.role !== "user")
133
+ return history;
134
+ const lead = `${preface}\n\n${context}\n\n`;
135
+ const content = typeof message.content === "string"
136
+ ? `${lead}${message.content}`
137
+ : [{ type: "text", text: lead }, ...message.content];
138
+ return history.map((item, at) => (at === index ? { ...message, content } : item));
139
+ }
140
+ /** A message's text, whether its content is a string or a list of parts. */
141
+ const textOf = (content) => {
142
+ if (typeof content === "string")
143
+ return content;
144
+ if (!Array.isArray(content))
145
+ return "";
146
+ return content
147
+ .map((part) => (typeof part?.text === "string" && part.type !== "refusal" ? part.text : ""))
148
+ .join("");
149
+ };
150
+ /**
151
+ * A stretch of a transcript as a hook reads it: what the user and the assistant said, and nothing
152
+ * else.
153
+ *
154
+ * Tool calls and their results are left out. They are the model's working rather than the
155
+ * conversation, and most of a transcript's characters; a memory server that filed them would
156
+ * recall a directory listing ahead of the decision it led to.
157
+ *
158
+ * The uuid is the session, the position and a digest of what was said. Position alone is not
159
+ * stable — a retry cuts the transcript back and writes a new answer at the same index, and a
160
+ * server deduping on it would keep the answer that was thrown away — and text alone would make
161
+ * two identical "ok"s one memory. The same message sent twice, after its turn and again when it
162
+ * is compacted, is one.
163
+ *
164
+ * @param sessionId Prefixes every uuid, so two sessions never share one.
165
+ * @param messages The transcript, in whatever shape the host stores it, so long as each message
166
+ * has an OpenAI-style `role` and `content`.
167
+ * @param from The first index, inclusive. Below zero reads from the start.
168
+ * @param to The end, exclusive. Absent, or past the end, reads to the end.
169
+ */
170
+ export function turnMessages(sessionId, messages, from, to) {
171
+ const end = Math.min(to ?? messages.length, messages.length);
172
+ const out = [];
173
+ for (let at = Math.max(0, from); at < end; at++) {
174
+ const message = messages[at];
175
+ if (message.role !== "user" && message.role !== "assistant")
176
+ continue;
177
+ const text = textOf(message.content).trim();
178
+ if (!text)
179
+ continue;
180
+ const digest = createHash("sha256").update(`${message.role}\0${text}`).digest("hex");
181
+ out.push({ speaker: message.role, text, uuid: `${sessionId}:${at}:${digest.slice(0, 12)}` });
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Which turn of a session begins at a point, from 0: the user messages ahead of it.
187
+ *
188
+ * @param messages The transcript.
189
+ * @param before Where the turn begins. Absent is the end, which is the index of a turn whose
190
+ * question has not been appended yet.
191
+ */
192
+ export const turnIndex = (messages, before = messages.length) => messages.slice(0, before).filter((message) => message.role === "user").length;
193
+ /** A runner that rejected, as the one outcome its event can still be noted by. */
194
+ const rejected = (event, error) => ({
195
+ serverId: "",
196
+ label: "",
197
+ hookId: "",
198
+ event,
199
+ ok: false,
200
+ error: errorMessage(error),
201
+ ms: 0,
202
+ inject: false,
203
+ maxTokens: 0,
204
+ });
205
+ const runSafely = (run, event, context, signal) => Promise.resolve()
206
+ .then(() => run(event, context, { signal }))
207
+ .catch((error) => [rejected(event, error)]);
208
+ /**
209
+ * Runs the hooks ahead of a request and builds what they add to it.
210
+ *
211
+ * This is on the path of the first token, so the events run together rather than one after the
212
+ * other, and a hook that fails costs the turn its context and never the turn — a runner that
213
+ * rejects outright is noted once for its event, with an empty `hookId` and `source`, and the rest
214
+ * go ahead. Bounding each hook's time is the runner's job; `signal` is how the turn ends all of
215
+ * them.
216
+ *
217
+ * @param run Runs one event's hooks.
218
+ * @param events Which to run: `["beforeTurn"]` ordinarily, and `sessionStart` ahead of it on a
219
+ * session's first turn. The outcomes are assembled in this order, so it is also the order the
220
+ * budget is spent in.
221
+ * @param context What the hooks are told.
222
+ * @param options `signal` is handed to the runner, and should be the turn's own: a user who
223
+ * stopped the turn stopped its recall. `onNote` hears each note as the whole is assembled.
224
+ * `maxTokens` is the shared budget for this request, read as `assembleContext` reads it: absent
225
+ * or unusable is the process's, from `configureHooks`.
226
+ */
227
+ export async function gather(run, events, context, { signal, onNote, maxTokens, } = {}) {
228
+ const outcomes = await Promise.all(events.map((event) => runSafely(run, event, context, signal)));
229
+ const gathered = assembleContext(outcomes.flat(), maxTokens);
230
+ for (const note of gathered.notes)
231
+ onNote?.(note);
232
+ return gathered;
233
+ }
234
+ /**
235
+ * Runs the hooks for an event that reads what happened and adds nothing to a request. Never
236
+ * rejects, so a host can fire it without awaiting it.
237
+ *
238
+ * No signal: these run once the turn has been answered, and a reader who stops listening at that
239
+ * point has not asked for the turn not to be remembered.
240
+ *
241
+ * @param run Runs the event's hooks.
242
+ * @param event `afterTurn`, `beforeCompact`, `sessionEnd` or `sessionDelete`. An injecting event
243
+ * works too, but what its hooks return is dropped, since there is no request here to add it to.
244
+ * @param context What the hooks are told.
245
+ * @param onNote Hears each note — which, with nothing injected, is only ever a failure.
246
+ * @returns The same notes.
247
+ */
248
+ export async function notify(run, event, context, onNote) {
249
+ const outcomes = await runSafely(run, event, context);
250
+ const notes = outcomes
251
+ .filter((outcome) => !outcome.ok)
252
+ .map((outcome) => assembleContext([outcome]).notes[0]);
253
+ for (const note of notes)
254
+ onNote?.(note);
255
+ return notes;
256
+ }
package/dist/index.d.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  * What is here is everything that does not know what the agent is *for*: making a tool schema
5
5
  * a strict server will accept, getting tool definitions in front of a model without paying for
6
6
  * all of them, reading one streamed turn back into a message, answering an endpoint that
7
- * refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
8
- * pooled client, and the rules about retrying. What is not here is the work — orchestration,
7
+ * refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
8
+ * the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
@@ -14,7 +14,8 @@ export type { CatalogServer } from "./catalog.ts";
14
14
  export { contextLimitFor, getClient, listModels, type ModelInfo, NO_KEY, resetClients, timeoutMs, } from "./client.ts";
15
15
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
16
16
  export { errorMessage } from "./errors.ts";
17
- export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
17
+ export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
18
+ export { assembleContext, configureHooks, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, type HookContext, type HookEvent, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, withContext, } from "./hooks.ts";
18
19
  export { resetAll } from "./reset.ts";
19
20
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
20
21
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
@@ -22,4 +23,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
22
23
  export { ask, clean, listLines, parseJson, resetHints, type SideTaskOptions, tryAsk, } from "./side-task.ts";
23
24
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
24
25
  export { estimateTokens } from "./tokens.ts";
25
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.ts";
26
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -4,15 +4,16 @@
4
4
  * What is here is everything that does not know what the agent is *for*: making a tool schema
5
5
  * a strict server will accept, getting tool definitions in front of a model without paying for
6
6
  * all of them, reading one streamed turn back into a message, answering an endpoint that
7
- * refuses one of those, one-shot calls that support a run, the event bus a watcher reads, a
8
- * pooled client, and the rules about retrying. What is not here is the work — orchestration,
7
+ * refuses one of those, one-shot calls that support a run, the host's side of lifecycle hooks,
8
+ * the event bus a watcher reads, a pooled client, and the rules about retrying. What is not here is the work — orchestration,
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
12
  export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
13
13
  export { contextLimitFor, getClient, listModels, NO_KEY, resetClients, timeoutMs, } from "./client.js";
14
14
  export { errorMessage } from "./errors.js";
15
- export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
15
+ export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
16
+ export { assembleContext, configureHooks, gather, HOOK_CONTEXT_TOKENS, HOOK_EVENTS, HOOK_PREFACE, INJECT_EVENTS, notify, resetHooks, turnIndex, turnMessages, withContext, } from "./hooks.js";
16
17
  export { resetAll } from "./reset.js";
17
18
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
18
19
  export { runTurn } from "./run-turn.js";
@@ -20,4 +21,4 @@ export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
20
21
  export { ask, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
21
22
  export { streamTurn, } from "./stream.js";
22
23
  export { estimateTokens } from "./tokens.js";
23
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, requestedNames, } from "./tool-loading.js";
24
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadResult, MAX_CARRIED, MAX_PER_LOAD, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
package/dist/reset.d.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * Forgets everything this package remembers between calls.
3
3
  *
4
- * Four modules here keep state for the life of the process, each for a good reason and each
4
+ * Five modules here keep state for the life of the process, each for a good reason and each
5
5
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
6
6
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
7
- * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
8
- * exported, because a test that means to clear one thing should say so.
7
+ * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
8
+ * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
9
+ * one thing should say so.
9
10
  *
10
- * This is for the other case, which is every teardown. What all four hold is *latched
11
+ * This is for the other case, which is every teardown. What they hold is *latched
11
12
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
12
13
  * next test can tell. Miss one and the suite becomes order-dependent in the way that passes
13
14
  * locally and fails in CI on a different shard: the test that latched it still passes, and the
14
15
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
15
16
  * was written that way and only passed because every case had been handed a hostname of its own.
16
17
  *
17
- * It is also the seam that does not need finding again. A fifth module with a cache is a fifth
18
+ * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
18
19
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
19
20
  */
20
21
  export declare function resetAll(): void;
package/dist/reset.js CHANGED
@@ -1,24 +1,26 @@
1
1
  import { resetCapabilities } from "./capabilities.js";
2
2
  import { resetClients } from "./client.js";
3
3
  import { resetEvents } from "./events.js";
4
+ import { resetHooks } from "./hooks.js";
4
5
  import { resetHints } from "./side-task.js";
5
6
  /**
6
7
  * Forgets everything this package remembers between calls.
7
8
  *
8
- * Four modules here keep state for the life of the process, each for a good reason and each
9
+ * Five modules here keep state for the life of the process, each for a good reason and each
9
10
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
10
11
  * out not to take `stream_options` or a grammar, the models that refused the no-thinking
11
- * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
12
- * exported, because a test that means to clear one thing should say so.
12
+ * hints, the event bus, and the hooks' configured budget. `resetClients`, `resetCapabilities`,
13
+ * `resetHints`, `resetEvents` and `resetHooks` stay exported, because a test that means to clear
14
+ * one thing should say so.
13
15
  *
14
- * This is for the other case, which is every teardown. What all four hold is *latched
16
+ * This is for the other case, which is every teardown. What they hold is *latched
15
17
  * refusals* — a fact one test taught the process about an endpoint, still true as far as the
16
18
  * next test can tell. Miss one and the suite becomes order-dependent in the way that passes
17
19
  * locally and fails in CI on a different shard: the test that latched it still passes, and the
18
20
  * one that reads the latch fails only when it happens to run second. `tests/side-task-hints.test.ts`
19
21
  * was written that way and only passed because every case had been handed a hostname of its own.
20
22
  *
21
- * It is also the seam that does not need finding again. A fifth module with a cache is a fifth
23
+ * It is also the seam that does not need finding again. A sixth module with a cache is a sixth
22
24
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
23
25
  */
24
26
  export function resetAll() {
@@ -26,4 +28,5 @@ export function resetAll() {
26
28
  resetCapabilities();
27
29
  resetHints();
28
30
  resetEvents();
31
+ resetHooks();
29
32
  }
package/dist/retry.d.ts CHANGED
@@ -14,6 +14,11 @@ export declare class EndpointSilent extends Error {
14
14
  /**
15
15
  * The request was bigger than the model will read. Its own class so nothing retries it: sending
16
16
  * the same too-large request again is the same refusal, one round trip later.
17
+ *
18
+ * `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
19
+ * endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
20
+ * or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
21
+ * it was built from as `cause`.
17
22
  */
18
23
  export declare class ContextOverflow extends Error {
19
24
  readonly name = "ContextOverflow";
package/dist/retry.js CHANGED
@@ -15,6 +15,11 @@ export class EndpointSilent extends Error {
15
15
  /**
16
16
  * The request was bigger than the model will read. Its own class so nothing retries it: sending
17
17
  * the same too-large request again is the same refusal, one round trip later.
18
+ *
19
+ * `runTurn` raises it from either side of the round trip — its own pre-flight guard, or the
20
+ * endpoint's refusal read back through `isOverflow` — so a caller has one thing to catch whether
21
+ * or not it gave a `contextLimit`. The second carries the endpoint's own message, and the error
22
+ * it was built from as `cause`.
18
23
  */
19
24
  export class ContextOverflow extends Error {
20
25
  name = "ContextOverflow";
@@ -12,6 +12,11 @@ import { type StreamTurnOptions, type Turn } from "./stream.ts";
12
12
  * The outer one is the endpoint being unreachable, busy or silent, which is not about this
13
13
  * request at all and is worth simply waiting out.
14
14
  *
15
+ * A request too big for the window is neither, and comes out of here as `ContextOverflow`
16
+ * however it was found out about — by the `contextLimit` guard below before a round trip was
17
+ * spent, or by the endpoint's own refusal after one. The option decides how early the caller
18
+ * hears, not what it hears.
19
+ *
15
20
  * Both are bounded by the same rule: nothing is sent again once the model has started
16
21
  * answering. The tokens are already out and on their way to whoever is watching, and a second
17
22
  * attempt would say everything twice. That is what `produced` is, one box per attempt — set by
package/dist/run-turn.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { negotiate } from "./capabilities.js";
2
2
  import { errorMessage } from "./errors.js";
3
- import { backoffMs, ContextOverflow, compact, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
3
+ import { backoffMs, ContextOverflow, compact, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
4
4
  import { streamTurn } from "./stream.js";
5
5
  /**
6
6
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -46,6 +46,19 @@ export async function runTurn(client, supports, request, { maxRetries = 0, onNot
46
46
  // rules in `retry.ts` — so classifying first brings a cancelled run back from the dead.
47
47
  if (produced.any || stream.signal?.aborted)
48
48
  throw error;
49
+ // The endpoint's own refusal, classified here rather than left to the caller. One failure
50
+ // had two error types depending on an option about something else: a caller that gave a
51
+ // `contextLimit` got `ContextOverflow` from the guard above, and one that did not — the
52
+ // default — got a raw SDK error and had to know to run `isOverflow` over its message
53
+ // itself. `runTurn` is offered as the whole loop, so the classification this package
54
+ // already knows how to do belongs inside it.
55
+ //
56
+ // The original is kept as `cause`, because the endpoint's wording is the half that names
57
+ // the number. A rate limit borrows the same words and is not one of these — `isOverflow`
58
+ // rules it out, and it goes on to be retried below as the 429 it is.
59
+ if (!(error instanceof ContextOverflow) && isOverflow(errorMessage(error))) {
60
+ throw new ContextOverflow(errorMessage(error), { cause: error });
61
+ }
49
62
  if (attempt >= maxRetries || !isTransient(error))
50
63
  throw error;
51
64
  const wait = backoffMs(attempt);
package/dist/side-task.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import OpenAI from "openai";
2
2
  import { capabilitiesFor, negotiate } from "./capabilities.js";
3
- import { getClient } from "./client.js";
3
+ import { endpointKey, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { isTransient } from "./retry.js";
6
6
  /**
@@ -24,10 +24,10 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
24
24
  /**
25
25
  * The models that turned out not to take the hints, by endpoint and model.
26
26
  *
27
- * Keyed rather than global for the reason the client cache is keyed: a refusal is a fact about
28
- * what is on the other end, not about this process. A llama.cpp box and a cloud API are both
29
- * reachable from one consumer over its lifetime, and the first one's refusal must not stop the
30
- * second from ever being asked.
27
+ * Keyed rather than global for the reason the client cache is keyed, and on the same
28
+ * `endpointKey` it is: a refusal is a fact about what is on the other end, not about this
29
+ * process. A llama.cpp box and a cloud API are both reachable from one consumer over its
30
+ * lifetime, and the first one's refusal must not stop the second from ever being asked.
31
31
  *
32
32
  * The model belongs in the key for the same reason. One base URL is routinely many models —
33
33
  * OpenRouter, LiteLLM, vLLM serving several at once — and whether `chat_template_kwargs` reaches
@@ -38,7 +38,7 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
38
38
  * the same (endpoint, model) pair, where a run on that model can read it too.
39
39
  */
40
40
  const noHints = new Set();
41
- const hintKey = (baseUrl, model) => JSON.stringify([baseUrl, model]);
41
+ const hintKey = (config, model) => JSON.stringify([endpointKey(config), model]);
42
42
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
43
43
  export const resetHints = () => noHints.clear();
44
44
  /**
@@ -108,12 +108,12 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
108
108
  // whether a run or a side task found it out, and the point of latching it is that only one of
109
109
  // them has to pay for it. Nothing here sends tools or `stream_options`, so the two
110
110
  // endpoint-level flags are not in play — the model's three are the whole of what this meets.
111
- const supports = capabilitiesFor(config.baseUrl);
111
+ const supports = capabilitiesFor(config.baseUrl, config.apiKey);
112
112
  const attempt = (hints) => negotiate(supports, (_supports, _produced, refused) => send(hints, refused), {
113
113
  model,
114
114
  onNotice,
115
115
  });
116
- const key = hintKey(config.baseUrl, model);
116
+ const key = hintKey(config, model);
117
117
  const hints = !noHints.has(key);
118
118
  let response;
119
119
  try {
package/dist/stream.d.ts CHANGED
@@ -18,8 +18,26 @@ export interface TurnUsage {
18
18
  /** One streamed turn, put back together into the shape a loop and a transcript work with. */
19
19
  export interface Turn {
20
20
  content: string;
21
- toolCalls: OpenAI.ChatCompletionMessageToolCall[];
21
+ /**
22
+ * The narrower of the SDK's two tool-call shapes, because it is the only one built here — a
23
+ * streamed `tool_calls` delta carries a function and nothing else. Typed as the union it
24
+ * belongs to, every caller had to narrow before it could read `.function`, to rule out a
25
+ * custom call that this loop cannot produce. Still assignable wherever the union is wanted.
26
+ */
27
+ toolCalls: OpenAI.ChatCompletionMessageFunctionToolCall[];
22
28
  usage: TurnUsage;
29
+ /**
30
+ * Why the model stopped, in the endpoint's own words — `stop`, `length`, `tool_calls`, or `""`
31
+ * where it never said.
32
+ *
33
+ * Reported because `length` is otherwise invisible. A turn cut off at the token ceiling comes
34
+ * back as a well-formed `Turn` with truncated `content`, or with a tool call whose `arguments`
35
+ * stop mid-JSON — so the caller meets a parse failure with nothing to attribute it to. Being
36
+ * cut off looking whole is the same trap `throwIfAborted` below answers for the abort; this
37
+ * half is not an error, because the tokens are real and a caller may still want them, so it is
38
+ * handed over rather than raised.
39
+ */
40
+ finishReason: string;
23
41
  }
24
42
  /**
25
43
  * Whether the model has said anything a second attempt would say twice.
package/dist/stream.js CHANGED
@@ -51,6 +51,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
51
51
  const content = [];
52
52
  const calls = new Map();
53
53
  const usage = { prompt: 0, completion: 0, total: 0 };
54
+ let finishReason = "";
54
55
  for await (const chunk of stream) {
55
56
  // Rearmed on every chunk, latched below on only some: a priming chunk is the endpoint
56
57
  // being alive, which is all the watchdog is asking about.
@@ -64,7 +65,15 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
64
65
  usage.completion = chunk.usage.completion_tokens ?? 0;
65
66
  usage.total = chunk.usage.total_tokens ?? 0;
66
67
  }
67
- const delta = chunk.choices[0]?.delta;
68
+ // One choice, because that is what an agent loop asks for. A body with `n` above one
69
+ // keeps only the first; nothing here is built to reassemble several at once.
70
+ const choice = chunk.choices[0];
71
+ // Read before the delta guard rather than beside the content. The chunk that carries the
72
+ // reason usually carries an empty delta, and some servers send it with no delta at all —
73
+ // either of which the guard below skips, taking the reason with it.
74
+ if (choice?.finish_reason)
75
+ finishReason = choice.finish_reason;
76
+ const delta = choice?.delta;
68
77
  if (!delta)
69
78
  continue;
70
79
  const thinking = delta.reasoning_content || delta.reasoning || "";
@@ -112,6 +121,7 @@ export async function streamTurn(client, body, { signal, idleMs, produced, onThi
112
121
  function: { name: call.name, arguments: call.arguments },
113
122
  })),
114
123
  usage,
124
+ finishReason,
115
125
  };
116
126
  }
117
127
  }