@cubicecho/agent-core 2.21.0 → 2.21.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
@@ -279,15 +279,24 @@ const turn = await runTurn(client, supports, build, {
279
279
  });
280
280
  ```
281
281
 
282
- `contextLimitFor` answers the operator's number when there is one. Otherwise it asks for the window
283
- the server is actually serving the model in (`servedWindow`): llama.cpp's `/props`
284
- (`default_generation_settings.n_ctx`) and LM Studio's `/api/v0/models` (`loaded_context_length`).
285
- That differs from the trained window in the case the guard exists for, a 256k model started at `-c
286
- 16384`. A server with neither route is latched and not asked again. Failing both, it reads the
287
- `/v1/models` listing: `max_model_len` from vLLM, `context_length` from OpenRouter, and llama.cpp's
288
- `meta.n_ctx_train`, which is only the trained window. Ollama reports no window on any route this
289
- reads, and truncates an over-long prompt rather than refusing it, so on Ollama pass `contextLength`
290
- or there is no guard at all.
282
+ `contextLimitFor` answers the operator's number when there is one. Otherwise it reads the
283
+ `/v1/models` listing first. A window at the top of the model's entry (`context_length`,
284
+ `max_context_window`, `max_model_len`, `context_window` or `n_ctx`: vLLM's `max_model_len`,
285
+ OpenRouter's `context_length`) is taken as the one being served, and nothing else is asked.
286
+
287
+ Where the listing does not settle it, the server's own API is asked (`servedWindow`): llama.cpp's
288
+ `/props` (`default_generation_settings.n_ctx`), then LM Studio's `/api/v0/models`
289
+ (`loaded_context_length`). That covers a model listed with no window, which is LM Studio, and one
290
+ listed with only `meta.n_ctx_train`, which is llama.cpp. The trained window differs from the served
291
+ one in the case the guard exists for, a 256k model started at `-c 16384`, so it is used only when
292
+ neither native route gives a number. A server with neither route is latched and not asked again.
293
+ `/api/v0/models` is also dropped on its own: for the life of the process once it answers 404, 405
294
+ or 501, and for `listingMissMs` after any other error.
295
+
296
+ A top-level listing key is trusted without a second opinion, so on a server that puts a trained
297
+ window there, pass `contextLength`. Ollama reports no window on any route this reads, and truncates
298
+ an over-long prompt rather than refusing it, so on Ollama pass `contextLength` or there is no guard
299
+ at all.
291
300
 
292
301
  What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
293
302
  was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
package/dist/client.d.ts CHANGED
@@ -18,7 +18,8 @@ export interface ClientPoolOptions {
18
18
  maxClients?: number;
19
19
  /**
20
20
  * How long, in milliseconds, a model an endpoint did not list — or a server that answered
21
- * without a served window — is taken at its word before the endpoint is asked again.
21
+ * without a served window, or whose `/api/v0/models` answered with an error other than a
22
+ * missing route — is taken at its word before the endpoint is asked again.
22
23
  */
23
24
  listingMissMs?: number;
24
25
  }
@@ -66,7 +67,12 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
66
67
  * @param config Where to send requests and how long to wait. An absent `apiKey` becomes `NO_KEY`.
67
68
  */
68
69
  export declare function getClient(config: Endpoint): OpenAI;
69
- /** A model an endpoint offers, and what it says the model will read. Zero means it did not say. */
70
+ /**
71
+ * A model an endpoint offers, and what it says the model will read. Zero means it did not say.
72
+ *
73
+ * The number is whatever the listing carried, which from llama.cpp is the trained window rather
74
+ * than the served one; `contextLimitFor` is the lookup that knows the difference.
75
+ */
70
76
  export interface ModelInfo {
71
77
  id: string;
72
78
  contextLength: number;
@@ -109,7 +115,8 @@ export declare const endpointId: (config: {
109
115
  apiKey?: string;
110
116
  }) => string;
111
117
  /**
112
- * The window a local server is actually serving a model in, which its listing does not say.
118
+ * The window a local server's own API says it is serving a model in, for the servers whose
119
+ * listing does not say.
113
120
  *
114
121
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
115
122
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -117,8 +124,12 @@ export declare const endpointId: (config: {
117
124
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
118
125
  * Ollama reports nothing on either; an operator there has to declare the window.
119
126
  *
120
- * Zero where no answer was found. A server without either route, which is every hosted API, is
121
- * latched and not asked again; one that could not be reached is not remembered at all.
127
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
128
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
129
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
130
+ * other error. A server that could not be reached is not remembered at all.
131
+ *
132
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
122
133
  *
123
134
  * @param config The endpoint, plus the model whose window is wanted.
124
135
  */
@@ -139,11 +150,18 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
139
150
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
140
151
  * the honest-looking number is a run that fails at the endpoint instead.
141
152
  *
142
- * Otherwise the window the server is actually serving the model in, where it has an API that
143
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
144
- * it does not name this model, since a model can arrive after the first listing was taken. A
145
- * server that will not list models still has to be able to run a turn: a failure here is an
146
- * unknown window, not a failed run.
153
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
154
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
155
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
156
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
157
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
158
+ * is left when it says nothing. A server that will not list models still has to be able to run a
159
+ * turn: a failure here is an unknown window, not a failed run.
160
+ *
161
+ * The listing goes first because it is the one route every server here has, and the native ones
162
+ * are two more requests that a server stating its window in the listing never needed. Asked
163
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
164
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
147
165
  *
148
166
  * @param config The endpoint, plus the model whose window is wanted.
149
167
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
package/dist/client.js CHANGED
@@ -162,18 +162,26 @@ const CONTEXT_KEYS = [
162
162
  "n_ctx",
163
163
  ];
164
164
  const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
165
+ /**
166
+ * A listing entry's window, and whether it is only the one the model was trained with.
167
+ *
168
+ * The two are told apart because they are trusted differently: a top-level key is taken as the
169
+ * window being served and settles the lookup, and a trained one is a ceiling the server may be
170
+ * running well under, so `contextLimitFor` still asks the native routes before settling for it.
171
+ */
165
172
  function contextLengthOf(model) {
166
173
  const record = model;
167
174
  for (const key of CONTEXT_KEYS) {
168
175
  const value = positive(record[key]);
169
176
  if (value)
170
- return value;
177
+ return { contextLength: value, trained: false };
171
178
  }
172
179
  // llama.cpp's, and the window the model was trained with rather than the one it is served in:
173
180
  // a 256k model started at `-c 16384` lists 262144 here. Better than nothing, and why the
174
- // served window is asked for first. `meta` is `null` while the model loads.
181
+ // served window is still asked for after it. `meta` is `null` while the model loads.
175
182
  const meta = record.meta;
176
- return positive(meta?.n_ctx_train);
183
+ const contextLength = positive(meta?.n_ctx_train);
184
+ return { contextLength, trained: contextLength > 0 };
177
185
  }
178
186
  /**
179
187
  * The last listing from each endpoint, so a run can size its window without a round trip.
@@ -190,6 +198,13 @@ function contextLengthOf(model) {
190
198
  * whatever was there rather than emptying it.
191
199
  */
192
200
  const listings = new Map();
201
+ /**
202
+ * The models in each endpoint's last listing whose window is only the trained one, by id.
203
+ *
204
+ * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
205
+ * it has always been. Replaced with each listing that lands, and left alone by one that fails.
206
+ */
207
+ const trainedOnly = new Map();
193
208
  /**
194
209
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
195
210
  *
@@ -243,8 +258,25 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
243
258
  * is asked again after `listingMissMs`, like a listing that did not name the model.
244
259
  */
245
260
  const served = new Map();
246
- /** Endpoints that answered both probes with a refusal, and are not asked again. */
261
+ /** Endpoints with neither native route, which are not asked for either again. */
247
262
  const unserved = new Set();
263
+ /**
264
+ * Endpoints whose `/api/v0/models` answered with an error, and when.
265
+ *
266
+ * `gone` is a route the server does not have (`NOT_THERE`), which is a fact about the server and
267
+ * stands until `resetClients`. Any other error stands for `listingMissMs` only, because it may be
268
+ * one the server gets over, and LM Studio latched for good on a passing 5xx would have no window
269
+ * at all until the process restarted, its listing carrying none.
270
+ *
271
+ * Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
272
+ * window is never `unserved`, and without this its missing route was asked again with every miss.
273
+ */
274
+ const refusals = new Map();
275
+ /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
276
+ const refused = (endpoint) => {
277
+ const refusal = refusals.get(endpoint);
278
+ return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
279
+ };
248
280
  /** How long a probe may take before the window is taken from the listing instead. */
249
281
  const PROBE_TIMEOUT_MS = 10_000;
250
282
  /** The server root behind an OpenAI-compatible base URL, which is where the native APIs live. */
@@ -252,8 +284,9 @@ const rootOf = (baseUrl) => baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
252
284
  /** A route this server does not have, rather than one that failed to answer. */
253
285
  const NOT_THERE = new Set([404, 405, 501]);
254
286
  /**
255
- * Asks one native endpoint. `missing` is a server without the route; `body` is absent for that
256
- * and for any other refusal, and a server that could not be reached throws.
287
+ * Asks one native endpoint. `failed` is any answer that was not a 2xx and `missing` the ones among
288
+ * them that mean a server without the route; `body` is absent for both, and for a 2xx that was not
289
+ * JSON. A server that could not be reached throws.
257
290
  */
258
291
  async function probe(config, path) {
259
292
  const apiKey = config.apiKey || undefined;
@@ -262,16 +295,17 @@ async function probe(config, path) {
262
295
  signal: AbortSignal.timeout(timeoutMs(config) ?? PROBE_TIMEOUT_MS),
263
296
  });
264
297
  if (!response.ok)
265
- return { missing: NOT_THERE.has(response.status) };
298
+ return { failed: true, missing: NOT_THERE.has(response.status) };
266
299
  try {
267
- return { missing: false, body: await response.json() };
300
+ return { failed: false, missing: false, body: await response.json() };
268
301
  }
269
302
  catch {
270
- return { missing: false };
303
+ return { failed: false, missing: false };
271
304
  }
272
305
  }
273
306
  /**
274
- * The window a local server is actually serving a model in, which its listing does not say.
307
+ * The window a local server's own API says it is serving a model in, for the servers whose
308
+ * listing does not say.
275
309
  *
276
310
  * llama.cpp reports it on `/props` as `default_generation_settings.n_ctx`, per slot, and LM
277
311
  * Studio on `/api/v0/models` as `loaded_context_length` while the model is loaded. Both differ
@@ -279,8 +313,12 @@ async function probe(config, path) {
279
313
  * built for, and reading the trained one lets an overflow through the guard meant to catch it.
280
314
  * Ollama reports nothing on either; an operator there has to declare the window.
281
315
  *
282
- * Zero where no answer was found. A server without either route, which is every hosted API, is
283
- * latched and not asked again; one that could not be reached is not remembered at all.
316
+ * Zero where no answer was found, which is remembered for `listingMissMs`. A server without
317
+ * either route is latched and not asked again. `/api/v0/models` is also dropped on its own: until
318
+ * `resetClients` once it answers as a route that is not there, and for `listingMissMs` after any
319
+ * other error. A server that could not be reached is not remembered at all.
320
+ *
321
+ * The listing is not read here; `contextLimitFor` reads it first and comes here after.
284
322
  *
285
323
  * @param config The endpoint, plus the model whose window is wanted.
286
324
  */
@@ -300,13 +338,19 @@ export async function servedWindow(config) {
300
338
  ?.default_generation_settings;
301
339
  window = positive(settings?.n_ctx);
302
340
  if (!window) {
303
- const lmstudio = await probe(config, "/api/v0/models");
304
- const { data } = (lmstudio.body ?? {});
305
- const entry = Array.isArray(data)
306
- ? data.find((model) => model?.id === config.model)
307
- : undefined;
308
- window = positive(entry?.loaded_context_length);
309
- if (props.missing && lmstudio.missing) {
341
+ if (!refused(endpoint)) {
342
+ const lmstudio = await probe(config, "/api/v0/models");
343
+ if (lmstudio.failed)
344
+ refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
345
+ else
346
+ refusals.delete(endpoint);
347
+ const { data } = (lmstudio.body ?? {});
348
+ const entry = Array.isArray(data)
349
+ ? data.find((model) => model?.id === config.model)
350
+ : undefined;
351
+ window = positive(entry?.loaded_context_length);
352
+ }
353
+ if (props.missing && refusals.get(endpoint)?.gone) {
310
354
  unserved.add(endpoint);
311
355
  return 0;
312
356
  }
@@ -325,10 +369,18 @@ export async function servedWindow(config) {
325
369
  */
326
370
  export async function listModels(config) {
327
371
  const { data } = await getClient(config).models.list();
372
+ const trained = new Set();
328
373
  const models = data
329
- .map((model) => ({ id: model.id, contextLength: contextLengthOf(model) }))
374
+ .map((model) => {
375
+ const window = contextLengthOf(model);
376
+ if (window.trained)
377
+ trained.add(model.id);
378
+ return { id: model.id, contextLength: window.contextLength };
379
+ })
330
380
  .sort((a, b) => a.id.localeCompare(b.id));
331
- listings.set(endpointKey(config), models);
381
+ const key = endpointKey(config);
382
+ listings.set(key, models);
383
+ trainedOnly.set(key, trained);
332
384
  return models;
333
385
  }
334
386
  /**
@@ -339,11 +391,18 @@ export async function listModels(config) {
339
391
  * happily load a 256k model at `-c 16384` and go on listing it as 256k — and a run refused on
340
392
  * the honest-looking number is a run that fails at the endpoint instead.
341
393
  *
342
- * Otherwise the window the server is actually serving the model in, where it has an API that
343
- * says (`servedWindow`), and failing that the endpoint's listing — asked once, and again whenever
344
- * it does not name this model, since a model can arrive after the first listing was taken. A
345
- * server that will not list models still has to be able to run a turn: a failure here is an
346
- * unknown window, not a failed run.
394
+ * Otherwise the endpoint's listing — asked once, and again whenever it does not name this model,
395
+ * since a model can arrive after the first listing was taken. A window stated at the top of the
396
+ * model's entry is taken as the one being served, and nothing else is asked. Where the listing
397
+ * did not settle it — no window, only llama.cpp's trained one, no entry, no listing — the
398
+ * server's own API is asked (`servedWindow`), and what it says wins; the trained window is what
399
+ * is left when it says nothing. A server that will not list models still has to be able to run a
400
+ * turn: a failure here is an unknown window, not a failed run.
401
+ *
402
+ * The listing goes first because it is the one route every server here has, and the native ones
403
+ * are two more requests that a server stating its window in the listing never needed. Asked
404
+ * first, they were also asked again every `listingMissMs` for the life of the process on a server
405
+ * whose `/props` answers without a window, by any host that sizes the window per turn.
347
406
  *
348
407
  * @param config The endpoint, plus the model whose window is wanted.
349
408
  * @param declared The operator's own number. Above zero it wins and the endpoint is not asked.
@@ -351,9 +410,6 @@ export async function listModels(config) {
351
410
  export async function contextLimitFor(config, declared = 0) {
352
411
  if (declared > 0)
353
412
  return declared;
354
- const window = await servedWindow(config);
355
- if (window > 0)
356
- return window;
357
413
  const key = endpointKey(config);
358
414
  const listed = () => listings.get(key)?.find((model) => model.id === config.model);
359
415
  // The listing is asked for again when it does not name this model, rather than only when
@@ -369,26 +425,30 @@ export async function contextLimitFor(config, declared = 0) {
369
425
  // Asked again, but not on every call. A model that is never coming answers the same zero
370
426
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
371
427
  // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
372
- if (asked !== undefined && Date.now() - asked < poolLimits.listingMissMs)
373
- return 0;
374
- // A failure is not remembered: an endpoint that was down when the last run started is not
375
- // an endpoint with no models, and a window nobody could ask about is not a failed run.
376
- try {
377
- await listModels(config);
378
- }
379
- catch {
380
- return 0;
428
+ if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
429
+ // A failure is not remembered: an endpoint that was down when the last run started is not
430
+ // an endpoint with no models, and a window nobody could ask about is not a failed run.
431
+ const answered = await listModels(config).then(() => true, () => false);
432
+ // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
433
+ // that skipped the request would push the deadline out ahead of any caller polling faster
434
+ // than the interval, which is a memory that never expires rather than one that expires in
435
+ // half a minute.
436
+ if (answered) {
437
+ if (listed())
438
+ misses.delete(missKey);
439
+ else
440
+ misses.set(missKey, Date.now());
441
+ }
381
442
  }
382
- // Recorded where the endpoint was actually asked, and only there. Stamping it on the calls
383
- // that skipped the request would push the deadline out ahead of any caller polling faster
384
- // than the interval, which is a memory that never expires rather than one that expires in
385
- // half a minute.
386
- if (listed())
387
- misses.delete(missKey);
388
- else
389
- misses.set(missKey, Date.now());
390
443
  }
391
- return listed()?.contextLength ?? 0;
444
+ const entry = listed();
445
+ const fromListing = entry?.contextLength ?? 0;
446
+ // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
447
+ // built with, and taking it over what `/props` says lets an overflow through the guard.
448
+ if (fromListing > 0 && !trainedOnly.get(key)?.has(config.model))
449
+ return fromListing;
450
+ const window = await servedWindow(config);
451
+ return window > 0 ? window : fromListing;
392
452
  }
393
453
  /**
394
454
  * Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
@@ -399,6 +459,8 @@ export function resetClients() {
399
459
  clients.clear();
400
460
  listings.clear();
401
461
  misses.clear();
462
+ trainedOnly.clear();
402
463
  served.clear();
403
464
  unserved.clear();
465
+ refusals.clear();
404
466
  }
package/llms.txt CHANGED
@@ -70,7 +70,7 @@ The contract between whatever holds the tools and the loop that offers them to a
70
70
  - `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
71
71
  - `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
72
72
  - `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
73
- - `servedWindow` — The window a local server is actually serving a model in, which its listing does not say.
73
+ - `servedWindow` — The window a local server's own API says it is serving a model in, for the servers whose listing does not say.
74
74
  - `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
75
75
 
76
76
  ### compaction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.21.0",
3
+ "version": "2.21.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",