@cubicecho/agent-core 1.3.0 → 2.0.1

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 CHANGED
@@ -27,10 +27,11 @@ only, Node >=22.
27
27
  | `side-task` | One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. |
28
28
  | `events` | The in-memory bus a watcher reads while a run happens. |
29
29
  | `client` | A pooled `OpenAI` client per endpoint, plus the context-window listing and its cache. |
30
- | `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`. |
30
+ | `retry` | What to do when a request is lost, refused or too big: `isTransient`, `backoffMs`, `ContextOverflow`, `EndpointSilent`, `requestTokens`. |
31
31
  | `config` | The structural interfaces every function here asks for. |
32
- | `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. |
32
+ | `run-turn` | `runTurn`: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in `contextLimit`. |
33
33
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
34
+ | `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
34
35
  | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
35
36
  | `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
36
37
 
@@ -73,7 +74,29 @@ anything, and the re-send reads it — so a caller with its own retry budget pas
73
74
  `idleMs` is silence, not a deadline: the timer is rearmed on every chunk, so a model that is
74
75
  still talking is never cut off however long it takes, and one that has stopped answering raises
75
76
  `EndpointSilent` rather than hanging the run. `timeoutMs(config)` returns `undefined` for a
76
- `requestTimeoutSeconds` of zero, which waits forever — what a local model answering slowly needs.
77
+ `requestTimeoutSeconds` of zero or absent, which waits forever — what a local model answering
78
+ slowly needs.
79
+
80
+ ## Sizing a request before sending it
81
+
82
+ `runTurn` will refuse a request that cannot fit rather than spending a round trip finding out:
83
+
84
+ ```ts
85
+ import { contextLimitFor, runTurn } from "@cubicecho/agent-core";
86
+
87
+ const turn = await runTurn(client, supports, build, {
88
+ maxRetries: 3,
89
+ // Opt-in: the number is the caller's to find. `contextLimitFor` asks the endpoint, and an
90
+ // operator's own setting overrides it — neither is network I/O a turn should be doing.
91
+ contextLimit: settings.contextLength || (await contextLimitFor(settings, model)),
92
+ onNotice: (message) => emit(runId, { kind: "notice", text: message }),
93
+ });
94
+ ```
95
+
96
+ The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
97
+ before it and the transcript does not change between retries. A `ContextOverflow` from this is
98
+ neither a capability `negotiate` can answer nor something `isTransient` accepts, so it leaves
99
+ both loops on the first attempt.
77
100
 
78
101
  ## The config seam
79
102
 
@@ -93,9 +116,8 @@ row has no `contextLength`; `min-agent` spells it `contextLimit` and carries no
93
116
  A single god interface would have forced two of them to grow columns they have no use for.
94
117
 
95
118
  The seam is not finished. `timeoutMs` narrows to the one field it reads, but `getClient` still
96
- asks for the whole of `Endpoint`, and `requestTimeoutSeconds` on it is required so a consumer
97
- that has no timeout to give must invent one (`0` means "no limit"). Making it optional is a
98
- breaking change and is waiting for the next major.
119
+ asks for the whole of `Endpoint`. `requestTimeoutSeconds` became optional in v2, so a consumer
120
+ with no timeout to give now leaves it out rather than inventing a `0`.
99
121
 
100
122
  ## Where the merged behaviour came from
101
123
 
package/dist/client.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { Endpoint } from "./config.ts";
6
6
  * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
7
7
  */
8
8
  export declare const NO_KEY = "agent-core";
9
- /** Zero or less means no limit, which the SDK spells as `undefined`. */
9
+ /** Zero, less, or absent means no limit, which the SDK spells as `undefined`. */
10
10
  export declare const timeoutMs: (config: Pick<Endpoint, "requestTimeoutSeconds">) => number | undefined;
11
11
  export declare function getClient(config: Endpoint): OpenAI;
12
12
  /** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
package/dist/client.js CHANGED
@@ -5,8 +5,11 @@ import OpenAI from "openai";
5
5
  * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
6
6
  */
7
7
  export const NO_KEY = "agent-core";
8
- /** Zero or less means no limit, which the SDK spells as `undefined`. */
9
- export const timeoutMs = (config) => config.requestTimeoutSeconds > 0 ? config.requestTimeoutSeconds * 1000 : undefined;
8
+ /** Zero, less, or absent means no limit, which the SDK spells as `undefined`. */
9
+ export const timeoutMs = (config) => {
10
+ const seconds = config.requestTimeoutSeconds ?? 0;
11
+ return seconds > 0 ? seconds * 1000 : undefined;
12
+ };
10
13
  /**
11
14
  * A client per endpoint, made once and kept.
12
15
  *
package/dist/config.d.ts CHANGED
@@ -17,8 +17,15 @@ export interface Endpoint {
17
17
  baseUrl: string;
18
18
  /** Empty is normal — a local server ignores it. See `getClient` for what is sent instead. */
19
19
  apiKey: string;
20
- /** Zero or less means no limit. */
21
- requestTimeoutSeconds: number;
20
+ /**
21
+ * Zero, less, or absent means no limit — what a local model answering slowly needs.
22
+ *
23
+ * Optional because a consumer that has no timeout to give should not have to invent one. Two
24
+ * of the three servers this was extracted from carry no such field, and requiring it made
25
+ * them write `requestTimeoutSeconds: 0` to mean "I have no opinion", which is a made-up
26
+ * number standing in for an absent one.
27
+ */
28
+ requestTimeoutSeconds?: number;
22
29
  }
23
30
  /** What to ask the model for. */
24
31
  export interface ModelParams {
package/dist/events.d.ts CHANGED
@@ -44,7 +44,15 @@ export interface RunEvent {
44
44
  runId: string;
45
45
  /** Per-run counter, from 1. Lets a client order and de-duplicate what it receives. */
46
46
  seq: number;
47
- at: Date;
47
+ /**
48
+ * When it happened, as epoch milliseconds.
49
+ *
50
+ * A number rather than a `Date`: these events are read over a wire, where a `Date` is an ISO
51
+ * string by the time anyone sees it, and `emit` runs once per streamed token — so the object
52
+ * it does not allocate is one per token. It also spares the `getTime()` the sweep used to do
53
+ * to get this same number back out.
54
+ */
55
+ at: number;
48
56
  kind: RunEventKind;
49
57
  /** The delta, the arguments, the result, or the reason — whatever the kind carries. */
50
58
  text: string;
@@ -86,8 +94,14 @@ export declare function emit(runId: string, input: RunEventInput): RunEvent;
86
94
  export declare function watch(runId: string): AsyncGenerator<RunEvent>;
87
95
  /** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
88
96
  export declare const history: (runId: string) => RunEvent[];
89
- /** Test seam: forget every run, so one test's events cannot be read by the next. */
90
- export declare const reset: () => void;
97
+ /**
98
+ * Test seam: forget every run, so one test's events cannot be read by the next.
99
+ *
100
+ * Named for what it forgets rather than bare `reset`, which sat in a consumer's imports beside
101
+ * `resetAll`, `resetClients`, `resetCapabilities` and `resetHints` saying nothing about which
102
+ * of the five it was — `reset.ts` had to alias it on the way in to stay readable.
103
+ */
104
+ export declare const resetEvents: () => void;
91
105
  /**
92
106
  * Consecutive tokens of one kind are one thing being said, not hundreds of things.
93
107
  *
package/dist/events.js CHANGED
@@ -76,8 +76,10 @@ export function endRun(runId) {
76
76
  /** Records one event and hands it to everyone watching that run. Never throws at the caller. */
77
77
  export function emit(runId, input) {
78
78
  const stream = streamFor(runId);
79
+ // One clock read, used for both the event and the sweep's bookkeeping.
80
+ const at = Date.now();
79
81
  const event = {
80
- at: new Date(),
82
+ at,
81
83
  text: "",
82
84
  name: "",
83
85
  step: "",
@@ -89,7 +91,7 @@ export function emit(runId, input) {
89
91
  seq: ++stream.seq,
90
92
  };
91
93
  stream.events.push(event);
92
- stream.touched = event.at.getTime();
94
+ stream.touched = at;
93
95
  if (stream.events.length > MAX_EVENTS + TRIM_SLACK) {
94
96
  stream.events.splice(0, stream.events.length - MAX_EVENTS);
95
97
  }
@@ -116,17 +118,71 @@ export function emit(runId, input) {
116
118
  */
117
119
  export async function* watch(runId) {
118
120
  const stream = streamFor(runId);
119
- const queue = [...stream.events];
121
+ // A cursor rather than `shift()`. Draining a backlog an event at a time off the front of an
122
+ // array is a copy of the whole array per event, which on the ten-thousand-delta run this bus
123
+ // is built for is the one quadratic left in the file. The prefix behind the cursor is dropped
124
+ // in one `slice` per `MAX_EVENTS` instead — the same amortised trade the bus itself makes.
125
+ let queue = [...stream.events];
126
+ let head = 0;
127
+ let dropped = 0;
120
128
  let wake = null;
121
129
  const listener = (event) => {
122
130
  queue.push(event);
131
+ // The bus caps its own backlog at `MAX_EVENTS`; without this the watcher downstream of it
132
+ // had no cap at all, so a client too slow to keep up held every delta a run ever emitted.
133
+ // The oldest go, which is what the backlog does, and the gap is reported once below.
134
+ //
135
+ // Dropped here means released here. Advancing the cursor alone left the dropped events in
136
+ // the slots behind it, to be freed by the compaction in the drain below — which a consumer
137
+ // that has stalled does not reach, and a stalled consumer is the whole reason for the cap.
138
+ // It read as capped and held every event anyway: 16MB where the cap promises a third of one.
139
+ const cut = queue.length - head - MAX_EVENTS;
140
+ if (cut > TRIM_SLACK) {
141
+ queue = queue.slice(head + cut);
142
+ head = 0;
143
+ dropped += cut;
144
+ }
123
145
  wake?.();
124
146
  };
125
147
  stream.listeners.add(listener);
126
148
  try {
127
149
  for (;;) {
128
- while (queue.length > 0) {
129
- const event = queue.shift();
150
+ while (head < queue.length) {
151
+ const event = queue[head++];
152
+ // What is behind the cursor is released rather than left there. Resetting only on catch-up
153
+ // was not enough: a watcher that keeps pace but never quite empties the queue never
154
+ // reaches that branch, and the array grows by a slot per event for the length of the run.
155
+ if (head === queue.length) {
156
+ queue = [];
157
+ head = 0;
158
+ }
159
+ else if (head > MAX_EVENTS) {
160
+ queue = queue.slice(head);
161
+ head = 0;
162
+ }
163
+ if (dropped > 0) {
164
+ // Said once per gap rather than per event, and before the event that follows it, so a
165
+ // client reading `seq` sees why the numbers jump instead of assuming it lost its place.
166
+ //
167
+ // One short of the event it precedes, which is the last seq that went missing. Sharing
168
+ // a seq with the event behind it made the notice indistinguishable from a duplicate,
169
+ // and de-duplicating on `seq` is the one thing the sequence is documented for — so a
170
+ // client doing exactly that dropped either the gap notice or the event it explains.
171
+ // Inside the gap there is nothing to collide with: those seqs reach no watcher.
172
+ const gap = dropped;
173
+ dropped = 0;
174
+ yield {
175
+ runId,
176
+ seq: event.seq - 1,
177
+ at: event.at,
178
+ kind: "notice",
179
+ text: `${gap} event(s) dropped: this watcher fell too far behind`,
180
+ name: "",
181
+ step: event.step,
182
+ ok: null,
183
+ usage: null,
184
+ };
185
+ }
130
186
  yield event;
131
187
  // `done` is the last event a run will ever have, so the subscription completes rather
132
188
  // than leaving the client holding an open stream that will never say anything again.
@@ -151,8 +207,14 @@ export async function* watch(runId) {
151
207
  }
152
208
  /** The backlog alone, for a caller that wants a snapshot rather than a subscription. */
153
209
  export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
154
- /** Test seam: forget every run, so one test's events cannot be read by the next. */
155
- export const reset = () => {
210
+ /**
211
+ * Test seam: forget every run, so one test's events cannot be read by the next.
212
+ *
213
+ * Named for what it forgets rather than bare `reset`, which sat in a consumer's imports beside
214
+ * `resetAll`, `resetClients`, `resetCapabilities` and `resetHints` saying nothing about which
215
+ * of the five it was — `reset.ts` had to alias it on the way in to stay readable.
216
+ */
217
+ export const resetEvents = () => {
156
218
  streams.clear();
157
219
  if (sweeping)
158
220
  clearTimeout(sweeping);
@@ -168,23 +230,37 @@ export const reset = () => {
168
230
  */
169
231
  export function fold(events) {
170
232
  const blocks = [];
233
+ // The text of the block still open, accumulated rather than re-concatenated. Rebuilding the
234
+ // block object per delta — a spread and a join of everything so far — is the same paragraph
235
+ // built ten thousand times to produce it once.
236
+ let parts = [];
237
+ const close = () => {
238
+ if (!parts.length)
239
+ return;
240
+ const last = blocks[blocks.length - 1];
241
+ if (parts.length > 1)
242
+ last.text = parts.join("");
243
+ parts = [];
244
+ };
171
245
  for (const event of events) {
172
246
  const last = blocks[blocks.length - 1];
173
247
  const mergeable = event.kind === "thinking" || event.kind === "output";
174
248
  if (last && mergeable && last.kind === event.kind && last.step === event.step) {
175
- blocks[blocks.length - 1] = {
176
- ...last,
177
- seq: event.seq,
178
- at: event.at,
179
- text: last.text + event.text,
180
- };
249
+ last.seq = event.seq;
250
+ last.at = event.at;
251
+ parts.push(event.text);
181
252
  }
182
253
  else {
183
- // A copy, because the merged branch above makes one and the caller cannot tell which
184
- // branch its events took. Pushing the stored object let `fold(history(id))[0].text = ...`
185
- // rewrite the bus, and every watcher after it read the rewrite.
254
+ close();
255
+ // A copy, because the merged branch above writes into the block it returns and the caller
256
+ // cannot tell which branch its events took. Pushing the stored object let
257
+ // `fold(history(id))[0].text = ...` rewrite the bus, and every watcher after it read the
258
+ // rewrite.
186
259
  blocks.push({ ...event });
260
+ if (mergeable)
261
+ parts.push(event.text);
187
262
  }
188
263
  }
264
+ close();
189
265
  return blocks;
190
266
  }
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ 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, reset, watch, } from "./events.ts";
17
+ export { emit, endRun, fold, history, type RunEvent, type RunEventInput, type RunEventKind, type RunUsage, resetEvents, watch, } from "./events.ts";
18
18
  export { resetAll } from "./reset.ts";
19
19
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.ts";
20
20
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  export { capabilitiesFor, 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, reset, watch, } from "./events.js";
15
+ export { emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
16
16
  export { resetAll } from "./reset.js";
17
17
  export { backoffMs, ContextOverflow, compact, EndpointSilent, isOverflow, isTransient, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, } from "./retry.js";
18
18
  export { runTurn } from "./run-turn.js";
package/dist/reset.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Four 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 `reset` stay
7
+ * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
8
8
  * exported, because a test that means to clear one thing should say so.
9
9
  *
10
10
  * This is for the other case, which is every teardown. What all four hold is *latched
package/dist/reset.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resetCapabilities } from "./capabilities.js";
2
2
  import { resetClients } from "./client.js";
3
- import { reset as resetEvents } from "./events.js";
3
+ import { resetEvents } from "./events.js";
4
4
  import { resetHints } from "./side-task.js";
5
5
  /**
6
6
  * Forgets everything this package remembers between calls.
@@ -8,7 +8,7 @@ import { resetHints } from "./side-task.js";
8
8
  * Four modules here keep state for the life of the process, each for a good reason and each
9
9
  * with its own seam: the pooled clients and their model listings, the endpoints that turned
10
10
  * 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 `reset` stay
11
+ * hints, and the event bus. `resetClients`, `resetCapabilities`, `resetHints` and `resetEvents` stay
12
12
  * exported, because a test that means to clear one thing should say so.
13
13
  *
14
14
  * This is for the other case, which is every teardown. What all four hold is *latched
package/dist/retry.d.ts CHANGED
@@ -23,17 +23,24 @@ export declare const compact: (tokens: number) => string;
23
23
  /**
24
24
  * What this request will cost the window, in tokens, near enough.
25
25
  *
26
- * Characters over four, because there is no tokenizer here and there is not going to be one:
27
- * a server that will not say how big its window is will not lend us its vocabulary either.
28
- * The estimate runs low on tool schemas JSON packs more tokens into a character than prose
29
- * does and that is the side to be wrong on, since the cost of guessing high is a run refused
30
- * that would have worked, and the cost of guessing low is the endpoint's own refusal, which is
31
- * where we were before this existed.
26
+ * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
27
+ *
28
+ * Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
29
+ * built the entire transcript into a string on every call and threw it away having read nothing
30
+ * but its `.length` against a transcript that grows by a turn each turn, and one the SDK is
31
+ * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
32
+ * which `ENVELOPE` puts back approximately; the difference is a rounding error against an
33
+ * estimate that is already characters over four.
32
34
  */
33
35
  export declare const requestTokens: (body: OpenAI.ChatCompletionCreateParamsStreaming) => number;
34
36
  export declare const isOverflow: (detail: string) => boolean;
35
37
  /**
36
- * Below this, the window is nobody's business and is not asked for.
38
+ * The smallest window worth believing in, and the floor under both of its uses.
39
+ *
40
+ * `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
41
+ * the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
42
+ * than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
43
+ * the window is nobody's business and is not asked for.
37
44
  *
38
45
  * Finding out what a model reads costs a listing against its endpoint, and a run whose whole
39
46
  * request is a few thousand tokens fits anything anyone serves — spending a round trip to
package/dist/retry.js CHANGED
@@ -21,18 +21,78 @@ export class ContextOverflow extends Error {
21
21
  }
22
22
  /** 1234 → "1.2k". The numbers in an overflow message are large and nobody reads the units digit. */
23
23
  export const compact = (tokens) => tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
24
+ /** What `{"role":"","content":""},` costs around a message's own text, in characters. */
25
+ const ENVELOPE = 25;
26
+ /** The same for `{"id":"","type":"function","function":{"name":"","arguments":""}},` in a call. */
27
+ const CALL_ENVELOPE = 62;
28
+ /** The divisor behind `estimateTokens`, applied here to a character count rather than a string. */
29
+ const CHARS_PER_TOKEN = 4;
30
+ /** How many characters one message is worth, whichever of the shapes its content is in. */
31
+ function messageChars(message) {
32
+ let chars = message.role.length + ENVELOPE;
33
+ const { content } = message;
34
+ if (typeof content === "string")
35
+ chars += content.length;
36
+ else if (Array.isArray(content))
37
+ for (const part of content) {
38
+ // Text and refusal parts carry their own strings; an image or an audio part carries a URL
39
+ // or a blob, and neither is priced by its length anyway.
40
+ if (part.type === "text")
41
+ chars += part.text.length;
42
+ else if (part.type === "refusal")
43
+ chars += part.refusal.length;
44
+ }
45
+ if ("name" in message && typeof message.name === "string")
46
+ chars += message.name.length;
47
+ if ("tool_call_id" in message && typeof message.tool_call_id === "string")
48
+ chars += message.tool_call_id.length;
49
+ if ("tool_calls" in message && Array.isArray(message.tool_calls))
50
+ for (const call of message.tool_calls) {
51
+ chars += CALL_ENVELOPE + call.id.length;
52
+ if (call.type === "function")
53
+ chars += call.function.name.length + call.function.arguments.length;
54
+ }
55
+ return chars;
56
+ }
57
+ /**
58
+ * The tools half, cached against the array.
59
+ *
60
+ * Tool definitions are stable objects handed out by a pool, and `sanitizeTools` already caches on
61
+ * that same identity — so the array a turn sends is the array the last turn sent unless something
62
+ * reconnected. Serialising two dozen JSON schemas to measure them, on every turn, to get the same
63
+ * number every time, was the more expensive half of this function.
64
+ */
65
+ const toolTokens = new WeakMap();
66
+ function toolsCost(tools) {
67
+ const hit = toolTokens.get(tools);
68
+ if (hit !== undefined)
69
+ return hit;
70
+ // Schemas are arbitrarily shaped, so this one really is a serialisation — but it happens once
71
+ // per tool array rather than once per turn.
72
+ const cost = estimateTokens(JSON.stringify(tools));
73
+ toolTokens.set(tools, cost);
74
+ return cost;
75
+ }
24
76
  /**
25
77
  * What this request will cost the window, in tokens, near enough.
26
78
  *
27
- * Characters over four, because there is no tokenizer here and there is not going to be one:
28
- * a server that will not say how big its window is will not lend us its vocabulary either.
29
- * The estimate runs low on tool schemas JSON packs more tokens into a character than prose
30
- * does and that is the side to be wrong on, since the cost of guessing high is a run refused
31
- * that would have worked, and the cost of guessing low is the endpoint's own refusal, which is
32
- * where we were before this existed.
79
+ * See `estimateTokens` for why it is characters over four and which way it is wrong on purpose.
80
+ *
81
+ * Summed by walking the body rather than by serialising it. `JSON.stringify` on the messages
82
+ * built the entire transcript into a string on every call and threw it away having read nothing
83
+ * but its `.length` against a transcript that grows by a turn each turn, and one the SDK is
84
+ * about to serialise again to send. What the walk misses is JSON's own punctuation and the keys,
85
+ * which `ENVELOPE` puts back approximately; the difference is a rounding error against an
86
+ * estimate that is already characters over four.
33
87
  */
34
- export const requestTokens = (body) => estimateTokens(JSON.stringify(body.messages)) +
35
- (body.tools?.length ? estimateTokens(JSON.stringify(body.tools)) : 0);
88
+ export const requestTokens = (body) => {
89
+ // Characters first and the division once at the end, rather than a rounded count per message:
90
+ // `Math.ceil` on every one of a few hundred messages is a few hundred tokens of pure rounding.
91
+ let chars = 0;
92
+ for (const message of body.messages)
93
+ chars += messageChars(message);
94
+ return Math.ceil(chars / CHARS_PER_TOKEN) + (body.tools?.length ? toolsCost(body.tools) : 0);
95
+ };
36
96
  /**
37
97
  * Servers refuse an over-long request in their own words; these are the ones worth reading as
38
98
  * that rather than as a broken request. Matched loosely — every one of them is some
@@ -58,7 +118,12 @@ export const isOverflow = (detail) => !RATE_LIMITED.test(detail) &&
58
118
  OVERFLOW.some((pattern) => pattern.test(detail)) &&
59
119
  /token|context/i.test(detail);
60
120
  /**
61
- * Below this, the window is nobody's business and is not asked for.
121
+ * The smallest window worth believing in, and the floor under both of its uses.
122
+ *
123
+ * `runTurn`'s `contextLimit` reads it as a sanity check on a number it was handed: below this,
124
+ * the limit is taken for a placeholder — an unset column, a listing that said nothing — rather
125
+ * than a window worth refusing a run over. `contextLimitFor` reads it as the point below which
126
+ * the window is nobody's business and is not asked for.
62
127
  *
63
128
  * Finding out what a model reads costs a listing against its endpoint, and a run whose whole
64
129
  * request is a few thousand tokens fits anything anyone serves — spending a round trip to
@@ -28,6 +28,18 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
28
28
  * see an unexplained pause. Carries both the capability notices and the retry notices.
29
29
  */
30
30
  onNotice?: (message: string) => void;
31
+ /**
32
+ * What the model will read, in tokens. Zero — the default — sends whatever it is given.
33
+ *
34
+ * With a limit, the request is sized before it is sent and a `ContextOverflow` is raised here
35
+ * rather than by the endpoint one round trip later. It is opt-in because the number is the
36
+ * caller's to find: `contextLimitFor` asks the endpoint, an operator's own setting overrides
37
+ * it, and neither is something a turn should be doing network I/O to discover. A limit below
38
+ * `SMALLEST_LIKELY_WINDOW` is not believed — a model with a window that small is rare enough
39
+ * that the number is far more likely a caller threading a placeholder through, and refusing a
40
+ * run over one would be the guard failing exactly the callers it was meant to help.
41
+ */
42
+ contextLimit?: number;
31
43
  }
32
44
  /**
33
45
  * `request` is a callback rather than a body because the body has to be rebuilt from whatever
@@ -35,4 +47,4 @@ export interface RunTurnOptions extends Omit<StreamTurnOptions, "produced"> {
35
47
  * has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
36
48
  * object throughout, and a caller that reads those from its own closure can ignore the argument.
37
49
  */
38
- export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, ...stream }?: RunTurnOptions): Promise<Turn>;
50
+ export declare function runTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities) => OpenAI.ChatCompletionCreateParamsStreaming, { maxRetries, onNotice, contextLimit, ...stream }?: RunTurnOptions): Promise<Turn>;
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, isTransient, sleep } from "./retry.js";
3
+ import { backoffMs, ContextOverflow, compact, 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
@@ -8,11 +8,30 @@ import { streamTurn } from "./stream.js";
8
8
  * has to apply to the schemas that were just sanitised. It is handed the same `Capabilities`
9
9
  * object throughout, and a caller that reads those from its own closure can ignore the argument.
10
10
  */
11
- export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, ...stream } = {}) {
11
+ export async function runTurn(client, supports, request, { maxRetries = 0, onNotice, contextLimit = 0, ...stream } = {}) {
12
+ // Sized once rather than per build. `request` is called again for every downgrade and every
13
+ // retry, but a downgraded body is strictly smaller than the one before it and the transcript
14
+ // does not change between attempts — so the first body is the one worth measuring, and
15
+ // measuring the rest would only spend the walk again to reach the same answer.
16
+ let sized = false;
17
+ const measured = (capabilities) => {
18
+ const body = request(capabilities);
19
+ if (!sized && contextLimit >= SMALLEST_LIKELY_WINDOW) {
20
+ sized = true;
21
+ const needed = requestTokens(body);
22
+ // Not retried, and deliberately not a capability: `isTransient` refuses it and none of the
23
+ // words below are ones `negotiate` reads as a refusal it can answer, so this leaves both
24
+ // loops on the first attempt instead of being sent again to be refused again.
25
+ if (needed > contextLimit) {
26
+ throw new ContextOverflow(`the request is about ${compact(needed)} tokens, over this model's ${compact(contextLimit)}`);
27
+ }
28
+ }
29
+ return body;
30
+ };
12
31
  for (let attempt = 0;; attempt++) {
13
32
  const produced = { any: false };
14
33
  try {
15
- return await negotiate(supports, (capabilities, box) => streamTurn(client, request(capabilities), { ...stream, produced: box }), { produced, onNotice });
34
+ return await negotiate(supports, (capabilities, box) => streamTurn(client, measured(capabilities), { ...stream, produced: box }), { produced, onNotice });
16
35
  }
17
36
  catch (error) {
18
37
  // The abort is read before the classification, not after. A run stopped by its operator
@@ -5,7 +5,7 @@ export declare const sanitizeTools: (tools: OpenAI.ChatCompletionTool[]) => Open
5
5
  * `pattern` and most `format` values, both of which only ever narrowed a string the tool
6
6
  * re-validates anyway.
7
7
  */
8
- export declare function relaxTools(tools: OpenAI.ChatCompletionTool[]): OpenAI.ChatCompletionTool[];
8
+ export declare const relaxTools: (tools: OpenAI.ChatCompletionTool[]) => OpenAI.ChatCompletionTool[];
9
9
  /**
10
10
  * Does this failure look like the server could not build a grammar from our tool schemas?
11
11
  *
@@ -190,64 +190,83 @@ function sanitizeParameters(parameters) {
190
190
  pruneRequired(out);
191
191
  return out;
192
192
  }
193
- const mapTools = (tools, fn) => tools.map((tool) => tool.type === "function"
194
- ? { ...tool, function: { ...tool.function, parameters: fn(tool.function.parameters) } }
195
- : tool);
193
+ /** Rewrites one tool's parameters, leaving a non-function tool alone. */
194
+ const mapTool = (tool, fn) => tool.type === "function"
195
+ ? {
196
+ ...tool,
197
+ function: { ...tool.function, parameters: fn(tool.function.parameters) },
198
+ }
199
+ : tool;
196
200
  /**
197
- * Cached against the tool object rather than recomputed.
201
+ * Both rewrites are cached against the tool object rather than recomputed.
198
202
  *
199
203
  * The agent loop rebuilds its tool array on every iteration of every step, and normalising a
200
204
  * couple of dozen MCP schemas is the only walk in a run that is neither a request nor a query.
201
205
  * The pool hands out the same definition objects for the life of a connection, so identity is
202
206
  * exactly the right key: a reconnect makes new ones and they are normalised again.
207
+ *
208
+ * Two maps rather than one, because the two answer different questions about the same tool and a
209
+ * relaxed schema is reached by way of a sanitised one. `relaxed` is the load-bearing half: an
210
+ * endpoint that has refused a grammar once has `strictSchemas` off for the life of the process
211
+ * (see `capabilities.ts`), so from that point every request takes this path and only this path.
212
+ * Caching the call that happens once per connection and not the one that happens on every request
213
+ * had it exactly the wrong way round.
214
+ *
215
+ * The contract both rely on is that a tool definition is not mutated in place. Nothing can evict
216
+ * an entry here — a caller that edits `tool.function.parameters` after the fact keeps the schema
217
+ * it had at first sight. Build a new definition object instead.
203
218
  */
204
219
  const sanitized = new WeakMap();
205
- export const sanitizeTools = (tools) => tools.map((tool) => {
206
- const hit = sanitized.get(tool);
220
+ const relaxed = new WeakMap();
221
+ /** Looks one up, computing and remembering it on a miss. */
222
+ const through = (cache, tools, fn) => tools.map((tool) => {
223
+ const hit = cache.get(tool);
207
224
  if (hit)
208
225
  return hit;
209
- const [clean] = mapTools([tool], sanitizeParameters);
210
- sanitized.set(tool, clean);
211
- return clean;
226
+ const built = mapTool(tool, fn);
227
+ cache.set(tool, built);
228
+ return built;
212
229
  });
230
+ export const sanitizeTools = (tools) => through(sanitized, tools, sanitizeParameters);
231
+ /**
232
+ * Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are keyword
233
+ * names and perfectly ordinary argument names at once. Matching on the key alone deleted a
234
+ * *property* called `format` along with the keyword, leaving the parent's `required` naming an
235
+ * argument that no longer existed — which every strict validator rejects, so the retry produced
236
+ * the failure it was reaching for. The same distinction keeps the walk out of `default`, `enum`
237
+ * and `const`, whose contents are data, not schema.
238
+ *
239
+ * At module scope rather than inside `relaxTools`, so the closure is made once rather than per
240
+ * call — which, on the path this is on, is per request.
241
+ */
242
+ const strip = (node) => {
243
+ if (Array.isArray(node))
244
+ return node.map(strip);
245
+ if (!isObject(node))
246
+ return node;
247
+ const out = {};
248
+ for (const [key, value] of Object.entries(node)) {
249
+ if (key === "pattern" || key === "format")
250
+ continue;
251
+ if (SCHEMA_KEYS.has(key))
252
+ out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
253
+ else if (SCHEMA_MAPS.has(key) && isObject(value))
254
+ // The keys here are argument names; only the values are schemas.
255
+ out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
256
+ else
257
+ out[key] = value;
258
+ }
259
+ return out;
260
+ };
213
261
  /**
214
262
  * The retry shape: llama.cpp's converter rejects regex escape classes (`\d`, `\w`, `\s`) in
215
263
  * `pattern` and most `format` values, both of which only ever narrowed a string the tool
216
264
  * re-validates anyway.
217
265
  */
218
- export function relaxTools(tools) {
219
- /**
220
- * Walked as a schema rather than as arbitrary JSON, because `pattern` and `format` are
221
- * keyword names and perfectly ordinary argument names at once. Matching on the key alone
222
- * deleted a *property* called `format` along with the keyword, leaving the parent's
223
- * `required` naming an argument that no longer existed — which every strict validator
224
- * rejects, so the retry produced the failure it was reaching for. The same distinction keeps
225
- * the walk out of `default`, `enum` and `const`, whose contents are data, not schema.
226
- */
227
- const strip = (node) => {
228
- if (Array.isArray(node))
229
- return node.map(strip);
230
- if (!isObject(node))
231
- return node;
232
- const out = {};
233
- for (const [key, value] of Object.entries(node)) {
234
- if (key === "pattern" || key === "format")
235
- continue;
236
- if (SCHEMA_KEYS.has(key))
237
- out[key] = Array.isArray(value) ? value.map(strip) : strip(value);
238
- else if (SCHEMA_MAPS.has(key) && isObject(value))
239
- // The keys here are argument names; only the values are schemas.
240
- out[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, strip(sub)]));
241
- else
242
- out[key] = value;
243
- }
244
- return out;
245
- };
246
- return mapTools(tools, (parameters) => {
247
- const stripped = strip(parameters);
248
- return isObject(stripped) ? stripped : EMPTY_OBJECT();
249
- });
250
- }
266
+ export const relaxTools = (tools) => through(relaxed, tools, (parameters) => {
267
+ const stripped = strip(parameters);
268
+ return isObject(stripped) ? stripped : EMPTY_OBJECT();
269
+ });
251
270
  /**
252
271
  * Qwen chat templates raise this when the transcript has no user turn. Some servers wrap it
253
272
  * in the same "unable to generate parser" wording as a real schema failure, and stripping
@@ -5,14 +5,22 @@ export interface SideTaskOptions {
5
5
  maxTokens?: number;
6
6
  temperature?: number;
7
7
  signal?: AbortSignal;
8
+ /**
9
+ * Told what was given up on, the same way `runTurn` and `negotiate` tell a caller.
10
+ *
11
+ * There is no default, and nothing is printed without one. A library that writes to the
12
+ * console decides for its consumer where operator text goes — which a server embedding this
13
+ * cannot then route to its own logger, attach to the run it belongs to, or silence in tests.
14
+ */
15
+ onNotice?: (message: string) => void;
8
16
  }
9
17
  /** Runs a side task and returns the reply text, thinking stripped. Throws like any request. */
10
- export declare function ask(config: Endpoint, model: string, system: string, user: string, { maxTokens, temperature, signal }?: SideTaskOptions): Promise<string>;
18
+ export declare function ask(config: Endpoint, model: string, system: string, user: string, { maxTokens, temperature, signal, onNotice }?: SideTaskOptions): Promise<string>;
11
19
  /**
12
20
  * A side task is never worth failing the work it supports. Callers that can carry on without
13
21
  * an answer use this and get `undefined` instead of an exception.
14
22
  */
15
- export declare function tryAsk<T>(label: string, run: () => Promise<T>): Promise<T | undefined>;
23
+ export declare function tryAsk<T>(label: string, run: () => Promise<T>, { onNotice }?: Pick<SideTaskOptions, "onNotice">): Promise<T | undefined>;
16
24
  /**
17
25
  * Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
18
26
  * the first array or object rather than failing the task over a wrapper.
package/dist/side-task.js CHANGED
@@ -63,7 +63,7 @@ function rejectedTheRequest(error) {
63
63
  */
64
64
  const stripThinking = (text) => text.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<think>[\s\S]*$/i, "");
65
65
  /** Runs a side task and returns the reply text, thinking stripped. Throws like any request. */
66
- export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal } = {}) {
66
+ export async function ask(config, model, system, user, { maxTokens = 512, temperature = 0.3, signal, onNotice } = {}) {
67
67
  const send = (hints) => getClient(config).chat.completions.create({
68
68
  model,
69
69
  max_tokens: maxTokens,
@@ -83,7 +83,7 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
83
83
  catch (error) {
84
84
  if (!hints || !rejectedTheRequest(error))
85
85
  throw error;
86
- console.warn("[side-task] server rejected the no-thinking hints; retrying without them");
86
+ onNotice?.("server rejected the no-thinking hints; retrying without them");
87
87
  noHints.add(key);
88
88
  response = await send(false);
89
89
  }
@@ -100,7 +100,7 @@ export async function ask(config, model, system, user, { maxTokens = 512, temper
100
100
  * A side task is never worth failing the work it supports. Callers that can carry on without
101
101
  * an answer use this and get `undefined` instead of an exception.
102
102
  */
103
- export async function tryAsk(label, run) {
103
+ export async function tryAsk(label, run, { onNotice } = {}) {
104
104
  try {
105
105
  return await run();
106
106
  }
@@ -109,7 +109,7 @@ export async function tryAsk(label, run) {
109
109
  // indistinguishable and left the cancellation with nowhere to go.
110
110
  if (error instanceof OpenAI.APIUserAbortError)
111
111
  throw error;
112
- console.warn(`[side-task] ${label}:`, errorMessage(error));
112
+ onNotice?.(`${label}: ${errorMessage(error)}`);
113
113
  return undefined;
114
114
  }
115
115
  }
package/dist/tokens.d.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  * guessing high is a run refused that would have worked, and the cost of guessing low is the
9
9
  * endpoint's own refusal, which is where we were before the guard existed.
10
10
  *
11
- * Its own module because both `retry` and `side-task` need it and they now need each other:
12
- * leaving it in `side-task` made the pair a cycle.
11
+ * Its own module because it is the one number several of these agree on, and the module that
12
+ * owns it should not be one that also does something. It was extracted from `side-task` to
13
+ * break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
14
+ * sizing its own prompt still do, and a leaf with no imports is the right home for it.
13
15
  */
14
16
  export declare const estimateTokens: (text: string) => number;
package/dist/tokens.js CHANGED
@@ -8,7 +8,9 @@
8
8
  * guessing high is a run refused that would have worked, and the cost of guessing low is the
9
9
  * endpoint's own refusal, which is where we were before the guard existed.
10
10
  *
11
- * Its own module because both `retry` and `side-task` need it and they now need each other:
12
- * leaving it in `side-task` made the pair a cycle.
11
+ * Its own module because it is the one number several of these agree on, and the module that
12
+ * owns it should not be one that also does something. It was extracted from `side-task` to
13
+ * break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
14
+ * sizing its own prompt still do, and a leaf with no imports is the right home for it.
13
15
  */
14
16
  export const estimateTokens = (text) => Math.ceil(text.length / 4);
@@ -14,7 +14,13 @@ import type { CatalogServer } from "./catalog.ts";
14
14
  * pays almost nothing, and a run that needs three pays for three.
15
15
  */
16
16
  export declare const LOAD_TOOLS = "load_tools";
17
- /** One object for the life of the process — the agent loop asks for it on every iteration. */
17
+ /**
18
+ * One object for the life of the process — the agent loop asks for it on every iteration.
19
+ *
20
+ * Frozen because it is shared: one mutable export reached by every consumer in the process
21
+ * means a caller that edits the description in place has edited it for all of them, in a place
22
+ * nobody would think to look for the change.
23
+ */
18
24
  export declare const LOAD_TOOLS_DEFINITION: OpenAI.ChatCompletionTool;
19
25
  /**
20
26
  * The catalogue as a plain grouped listing of names, loaded ones marked.
@@ -12,8 +12,21 @@
12
12
  * pays almost nothing, and a run that needs three pays for three.
13
13
  */
14
14
  export const LOAD_TOOLS = "load_tools";
15
- /** One object for the life of the process — the agent loop asks for it on every iteration. */
16
- export const LOAD_TOOLS_DEFINITION = {
15
+ /** Shallow freezing this one would leave `.function.description` — the part worth editing. */
16
+ function deepFreeze(value) {
17
+ if (value && typeof value === "object")
18
+ for (const held of Object.values(value))
19
+ deepFreeze(held);
20
+ return Object.freeze(value);
21
+ }
22
+ /**
23
+ * One object for the life of the process — the agent loop asks for it on every iteration.
24
+ *
25
+ * Frozen because it is shared: one mutable export reached by every consumer in the process
26
+ * means a caller that edits the description in place has edited it for all of them, in a place
27
+ * nobody would think to look for the change.
28
+ */
29
+ export const LOAD_TOOLS_DEFINITION = deepFreeze({
17
30
  type: "function",
18
31
  function: {
19
32
  name: LOAD_TOOLS,
@@ -34,7 +47,7 @@ export const LOAD_TOOLS_DEFINITION = {
34
47
  additionalProperties: false,
35
48
  },
36
49
  },
37
- };
50
+ });
38
51
  /**
39
52
  * The catalogue as a plain grouped listing of names, loaded ones marked.
40
53
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "1.3.0",
3
+ "version": "2.0.1",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",
@@ -46,7 +46,9 @@
46
46
  "test": "vitest run",
47
47
  "test:watch": "vitest",
48
48
  "lint": "biome check .",
49
- "format": "biome check --write ."
49
+ "format": "biome check --write .",
50
+ "bench": "vitest bench --run",
51
+ "coverage": "vitest run --coverage"
50
52
  },
51
53
  "peerDependencies": {
52
54
  "openai": ">=6"
@@ -56,6 +58,7 @@
56
58
  "@semantic-release/changelog": "^7.0.0",
57
59
  "@semantic-release/git": "^11.0.1",
58
60
  "@types/node": "^26.4.0",
61
+ "@vitest/coverage-v8": "^4.1.11",
59
62
  "openai": "^7.8.0",
60
63
  "semantic-release": "^25.0.9",
61
64
  "typescript": "^7.0.2",