@cubicecho/agent-core 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -585,6 +585,25 @@ OpenRouter `:free` suffix, a typo) would otherwise fetch the listing on every ca
585
585
  same zero each time. Half a minute later it asks again, so a model pulled onto a box that has been
586
586
  up a week is still picked up without a restart.
587
587
 
588
+ Both numbers are defaults. `configureClients` moves them for the process, the way `configureEvents`
589
+ and `configureHooks` do — a multi-tenant host raising the pool so tenants stop evicting each other,
590
+ a dev box shortening the miss window so a model it has just pulled shows up sooner:
591
+
592
+ ```ts
593
+ import { configureClients } from "@cubicecho/agent-core";
594
+
595
+ configureClients({ maxClients: 256, listingMissMs: 2_000 });
596
+ ```
597
+
598
+ A field left out, or given anything that is not a number above zero, keeps what it has, and the
599
+ call returns everything in force. Lowering `maxClients` below the pool's current size evicts down to
600
+ it at once, least recently used first. `resetClients` (and `resetAll`) puts the defaults back.
601
+
602
+ A host that keeps per-endpoint state of its own should key it on `endpointKey` — the JSON of the base
603
+ URL and the key, an absent key read as `NO_KEY` — rather than rebuilding that string, so the two
604
+ cannot drift. `endpointKey` holds the key in the clear; `endpointId`, its SHA-256 digest, is the one
605
+ that is safe to write down.
606
+
588
607
  `resetAll` drops all four, and `reset.ts` names each seam separately for a test that wants one.
589
608
 
590
609
  The latches can outlive the process as well, because otherwise every restart spends one refused
package/dist/client.d.ts CHANGED
@@ -12,6 +12,36 @@ export declare const NO_KEY = "agent-core";
12
12
  * @param config Read for `requestTimeoutSeconds` alone.
13
13
  */
14
14
  export declare const timeoutMs: (config: Pick<Endpoint, "requestTimeoutSeconds">) => number | undefined;
15
+ /** What the client pool is held to across a process. Every field optional; see `configureClients`. */
16
+ export interface ClientPoolOptions {
17
+ /** How many endpoints' clients are kept at once. The least recently asked for goes first. */
18
+ maxClients?: number;
19
+ /**
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.
22
+ */
23
+ listingMissMs?: number;
24
+ }
25
+ /**
26
+ * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
27
+ * deployments these defaults were chosen for.
28
+ *
29
+ * Module-level for the same reason `configureEvents` is: the pool is one module-level thing, and
30
+ * its size is a deployment's setting, said once at startup. A multi-tenant host keying on a key
31
+ * per user raises `maxClients` so its tenants stop evicting each other's connection pools; a dev
32
+ * box shortens `listingMissMs` so a model it has just pulled is seen sooner.
33
+ *
34
+ * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
35
+ * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses
36
+ * already remembered, since each is a timestamp compared against it when read.
37
+ *
38
+ * @param options The bounds 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: as `maxClients` it lifts the bound, and as `listingMissMs` a miss is never asked
41
+ * about again until `resetClients`.
42
+ * @returns Everything in force afterwards, including what this call did not change.
43
+ */
44
+ export declare function configureClients(options?: ClientPoolOptions): Required<ClientPoolOptions>;
15
45
  /** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
16
46
  export declare const FIRST_TOKEN_FACTOR = 5;
17
47
  /**
@@ -29,7 +59,7 @@ export declare const firstTokenMs: (config: Pick<Endpoint, "requestTimeoutSecond
29
59
  * The pool is per *deployment*, not per request: what belongs in this map is an endpoint an
30
60
  * operator configured, and everything cached in this package is bounded on that reading. A
31
61
  * consumer that mints an API key per user still gets a working client, but it is churning
32
- * connection pools rather than sharing them and holding every one of them — `MAX_CLIENTS` keeps
62
+ * connection pools rather than sharing them and holding every one of them — `maxClients` keeps
33
63
  * that from being unbounded, and it is the point at which a client of your own, built and held
34
64
  * per tenant, is the better answer than this.
35
65
  *
@@ -43,7 +73,7 @@ export interface ModelInfo {
43
73
  }
44
74
  /**
45
75
  * What counts as one endpoint, everywhere in this package that has to remember something about
46
- * one — this file's listings, `capabilities`, and the no-thinking hints in `side-task`.
76
+ * one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
47
77
  *
48
78
  * The URL and the key together, because the key is part of what is on the other end rather than
49
79
  * only how it is paid for: a router is free to send two keys to two different backends, and then
@@ -51,7 +81,9 @@ export interface ModelInfo {
51
81
  * endpoint with no key and one that passes `undefined` are one entry rather than two.
52
82
  *
53
83
  * Stringified rather than joined on a separator, for the reason `getClient` gives: no character
54
- * is impossible in a URL or a key, and two endpoints must never collide on one entry.
84
+ * is impossible in a URL or a key, and two endpoints must never collide on one entry. A host
85
+ * keeping its own per-endpoint state keys it on this rather than on a copy of it, so the two
86
+ * cannot drift apart. It holds the key in the clear; `endpointId` is the one to write down.
55
87
  *
56
88
  * @param config Read for `baseUrl` and `apiKey` alone, and the key is optional here where
57
89
  * `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
@@ -67,7 +99,8 @@ export declare const endpointKey: (config: {
67
99
  *
68
100
  * What an endpoint refused is exported by `exportCapabilities` to be written into a settings row
69
101
  * or a file, and a key inside that blob is a credential copied somewhere nobody meant to keep one.
70
- * A digest identifies the same endpoint on the next boot without saying what the key was.
102
+ * A digest identifies the same endpoint on the next boot without saying what the key was, and it
103
+ * is how a host finds its own endpoint's entry in a `CapabilitySnapshot`.
71
104
  *
72
105
  * @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
73
106
  */
@@ -118,5 +151,8 @@ export declare function listModels(config: Endpoint): Promise<ModelInfo[]>;
118
151
  export declare function contextLimitFor(config: Endpoint & {
119
152
  model: string;
120
153
  }, declared?: number): Promise<number>;
121
- /** Forgets every cached client and listing. For tests, and for a settings change under test. */
154
+ /**
155
+ * Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
156
+ * tests, and for a settings change under test.
157
+ */
122
158
  export declare function resetClients(): void;
package/dist/client.js CHANGED
@@ -33,7 +33,7 @@ export const timeoutMs = (config) => {
33
33
  */
34
34
  const clients = new Map();
35
35
  /**
36
- * How many endpoints' clients are kept at once.
36
+ * How many endpoints' clients are kept at once, until `configureClients` moves it.
37
37
  *
38
38
  * This is a backstop rather than a design. The key includes the API key, and every argument in
39
39
  * this file for what bounds these caches is "a settings row's worth" — true of the deployments
@@ -47,6 +47,56 @@ const clients = new Map();
47
47
  * constraint.
48
48
  */
49
49
  const MAX_CLIENTS = 32;
50
+ /** How long a model an endpoint did not list stays unlisted before it is asked about again. */
51
+ const LISTING_MISS_MS = 30_000;
52
+ /** The numbers this module was written with. */
53
+ const CLIENT_DEFAULTS = {
54
+ maxClients: MAX_CLIENTS,
55
+ listingMissMs: LISTING_MISS_MS,
56
+ };
57
+ /** What is in force now. Read where it is used, so a change applies from the next call. */
58
+ let poolLimits = { ...CLIENT_DEFAULTS };
59
+ /**
60
+ * Drops the least recently asked-for clients until the pool fits. Nothing is closed on the way
61
+ * out: the SDK holds no handle a caller can release, and an evicted client is garbage once
62
+ * whatever request is still in flight on it has finished — dropping the reference is the whole
63
+ * of the eviction.
64
+ */
65
+ const evict = () => {
66
+ for (const oldest of clients.keys()) {
67
+ if (clients.size <= poolLimits.maxClients)
68
+ break;
69
+ clients.delete(oldest);
70
+ }
71
+ };
72
+ /**
73
+ * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
74
+ * deployments these defaults were chosen for.
75
+ *
76
+ * Module-level for the same reason `configureEvents` is: the pool is one module-level thing, and
77
+ * its size is a deployment's setting, said once at startup. A multi-tenant host keying on a key
78
+ * per user raises `maxClients` so its tenants stop evicting each other's connection pools; a dev
79
+ * box shortens `listingMissMs` so a model it has just pulled is seen sooner.
80
+ *
81
+ * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
82
+ * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses
83
+ * already remembered, since each is a timestamp compared against it when read.
84
+ *
85
+ * @param options The bounds to change. A field left out — or given anything that is not a number
86
+ * above zero — keeps what it has, so a half-built config narrows nothing. `Infinity` is a number
87
+ * above zero: as `maxClients` it lifts the bound, and as `listingMissMs` a miss is never asked
88
+ * about again until `resetClients`.
89
+ * @returns Everything in force afterwards, including what this call did not change.
90
+ */
91
+ export function configureClients(options = {}) {
92
+ for (const [name, value] of Object.entries(options)) {
93
+ if (typeof value === "number" && value > 0) {
94
+ poolLimits[name] = value;
95
+ }
96
+ }
97
+ evict();
98
+ return { ...poolLimits };
99
+ }
50
100
  /** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
51
101
  export const FIRST_TOKEN_FACTOR = 5;
52
102
  /**
@@ -70,7 +120,7 @@ export const firstTokenMs = (config) => {
70
120
  * The pool is per *deployment*, not per request: what belongs in this map is an endpoint an
71
121
  * operator configured, and everything cached in this package is bounded on that reading. A
72
122
  * consumer that mints an API key per user still gets a working client, but it is churning
73
- * connection pools rather than sharing them and holding every one of them — `MAX_CLIENTS` keeps
123
+ * connection pools rather than sharing them and holding every one of them — `maxClients` keeps
74
124
  * that from being unbounded, and it is the point at which a client of your own, built and held
75
125
  * per tenant, is the better answer than this.
76
126
  *
@@ -92,14 +142,7 @@ export function getClient(config) {
92
142
  }
93
143
  const client = new OpenAI({ baseURL: config.baseUrl, apiKey, timeout, maxRetries: 0 });
94
144
  clients.set(key, client);
95
- // Nothing is closed on the way out. The SDK holds no handle a caller can release, and an
96
- // evicted client is garbage once whatever request is still in flight on it has finished —
97
- // dropping the reference is the whole of the eviction.
98
- for (const oldest of clients.keys()) {
99
- if (clients.size <= MAX_CLIENTS)
100
- break;
101
- clients.delete(oldest);
102
- }
145
+ evict();
103
146
  return client;
104
147
  }
105
148
  /**
@@ -156,17 +199,15 @@ const listings = new Map();
156
199
  * match the configured name, an OpenRouter `:free` suffix, a typo in a settings row — and there
157
200
  * every call fetches the listing, re-reads it, finds the same absence and answers the same zero.
158
201
  *
159
- * Remembering the miss for a moment answers both: within `LISTING_MISS_MS` nobody is asked, and
202
+ * Remembering the miss for a moment answers both: within `listingMissMs` nobody is asked, and
160
203
  * after it the endpoint is asked again, so an `ollama pull` on a box that has been up a week is
161
204
  * picked up within the minute instead of at the next restart. Bounded by the (endpoint, model)
162
205
  * pairs actually asked about, which is the bound the listings themselves have.
163
206
  */
164
207
  const misses = new Map();
165
- /** How long a model an endpoint did not list stays unlisted before it is asked about again. */
166
- const LISTING_MISS_MS = 30_000;
167
208
  /**
168
209
  * What counts as one endpoint, everywhere in this package that has to remember something about
169
- * one — this file's listings, `capabilities`, and the no-thinking hints in `side-task`.
210
+ * one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
170
211
  *
171
212
  * The URL and the key together, because the key is part of what is on the other end rather than
172
213
  * only how it is paid for: a router is free to send two keys to two different backends, and then
@@ -174,7 +215,9 @@ const LISTING_MISS_MS = 30_000;
174
215
  * endpoint with no key and one that passes `undefined` are one entry rather than two.
175
216
  *
176
217
  * Stringified rather than joined on a separator, for the reason `getClient` gives: no character
177
- * is impossible in a URL or a key, and two endpoints must never collide on one entry.
218
+ * is impossible in a URL or a key, and two endpoints must never collide on one entry. A host
219
+ * keeping its own per-endpoint state keys it on this rather than on a copy of it, so the two
220
+ * cannot drift apart. It holds the key in the clear; `endpointId` is the one to write down.
178
221
  *
179
222
  * @param config Read for `baseUrl` and `apiKey` alone, and the key is optional here where
180
223
  * `Endpoint` requires it — `capabilitiesFor` is handed a URL and maybe a key rather than a whole
@@ -187,7 +230,8 @@ export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.ap
187
230
  *
188
231
  * What an endpoint refused is exported by `exportCapabilities` to be written into a settings row
189
232
  * or a file, and a key inside that blob is a credential copied somewhere nobody meant to keep one.
190
- * A digest identifies the same endpoint on the next boot without saying what the key was.
233
+ * A digest identifies the same endpoint on the next boot without saying what the key was, and it
234
+ * is how a host finds its own endpoint's entry in a `CapabilitySnapshot`.
191
235
  *
192
236
  * @param config Read for `baseUrl` and `apiKey` alone, as `endpointKey` reads it.
193
237
  */
@@ -196,7 +240,7 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
196
240
  * Served windows found by asking a server's own API, keyed on endpoint and model together.
197
241
  *
198
242
  * A model's entry stays until `resetClients`, like a listing; a server that answered without one
199
- * is asked again after `LISTING_MISS_MS`, like a listing that did not name the model.
243
+ * is asked again after `listingMissMs`, like a listing that did not name the model.
200
244
  */
201
245
  const served = new Map();
202
246
  /** Endpoints that answered both probes with a refusal, and are not asked again. */
@@ -246,7 +290,7 @@ export async function servedWindow(config) {
246
290
  return 0;
247
291
  const key = JSON.stringify([endpoint, config.model]);
248
292
  const known = served.get(key);
249
- if (known && (known.window > 0 || Date.now() - known.at < LISTING_MISS_MS))
293
+ if (known && (known.window > 0 || Date.now() - known.at < poolLimits.listingMissMs))
250
294
  return known.window;
251
295
  let window = 0;
252
296
  try {
@@ -324,8 +368,8 @@ export async function contextLimitFor(config, declared = 0) {
324
368
  const asked = misses.get(missKey);
325
369
  // Asked again, but not on every call. A model that is never coming answers the same zero
326
370
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
327
- // trip for each of them; `LISTING_MISS_MS` is how long that answer is allowed to stand.
328
- if (asked !== undefined && Date.now() - asked < LISTING_MISS_MS)
371
+ // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
372
+ if (asked !== undefined && Date.now() - asked < poolLimits.listingMissMs)
329
373
  return 0;
330
374
  // A failure is not remembered: an endpoint that was down when the last run started is not
331
375
  // an endpoint with no models, and a window nobody could ask about is not a failed run.
@@ -346,8 +390,12 @@ export async function contextLimitFor(config, declared = 0) {
346
390
  }
347
391
  return listed()?.contextLength ?? 0;
348
392
  }
349
- /** Forgets every cached client and listing. For tests, and for a settings change under test. */
393
+ /**
394
+ * Forgets every cached client and listing, and puts `configureClients` back to the defaults. For
395
+ * tests, and for a settings change under test.
396
+ */
350
397
  export function resetClients() {
398
+ poolLimits = { ...CLIENT_DEFAULTS };
351
399
  clients.clear();
352
400
  listings.clear();
353
401
  misses.clear();
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type ToolCallOutcome, type ToolCallRequest, } from "./agent-loop.ts";
13
13
  export { type Capabilities, capabilitiesFor, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
14
14
  export type { CatalogServer } from "./catalog.ts";
15
- export { contextLimitFor, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
15
+ export { type ClientPoolOptions, configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, type ModelInfo, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.ts";
16
16
  export { COMPACT_AT, type CompactionOptions, type CompactionPlan, compactTranscript, KEEP_RATIO, type PruneOptions, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.ts";
17
17
  export type { AgentConfig, Endpoint, ModelParams, RetryPolicy, ToolPolicy, } from "./config.ts";
18
18
  export { errorMessage } from "./errors.ts";
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
  export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
13
13
  export { capabilitiesFor, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
14
- export { contextLimitFor, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
14
+ export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
15
15
  export { COMPACT_AT, compactTranscript, KEEP_RATIO, planCompaction, pruneToolResults, SUMMARY_LEAD, SUMMARY_PROMPT, summariser, summaryInput, } from "./compaction.js";
16
16
  export { errorMessage } from "./errors.js";
17
17
  export { configureEvents, emit, endRun, fold, history, resetEvents, watch, } from "./events.js";
package/llms.txt CHANGED
@@ -44,14 +44,18 @@ The contract between whatever holds the tools and the loop that offers them to a
44
44
 
45
45
  ### client
46
46
 
47
+ - `ClientPoolOptions` (type) — What the client pool is held to across a process.
48
+ - `configureClients` — Changes what the client pool is held to, for a process whose endpoints are not shaped like the deployments these defaults were chosen for.
47
49
  - `contextLimitFor` — How much a model will read, in tokens.
50
+ - `endpointId` — `endpointKey` hashed, for the remembered facts that can leave the process.
51
+ - `endpointKey` — What counts as one endpoint, everywhere in this package that has to remember something about one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
48
52
  - `FIRST_TOKEN_FACTOR` — How many idle windows the first chunk gets when `firstTokenSeconds` is not given.
49
53
  - `firstTokenMs` — The wait for a streamed turn's first chunk, in the SDK's spelling: `undefined` is no limit.
50
54
  - `getClient` — The client for an endpoint, built once and kept.
51
55
  - `listModels` — Asks an endpoint what it serves, and remembers the answer.
52
56
  - `ModelInfo` (type) — A model an endpoint offers, and what it says the model will read.
53
57
  - `NO_KEY` — The SDK insists on a non-empty key even where the server will not look at it.
54
- - `resetClients` — Forgets every cached client and listing.
58
+ - `resetClients` — Forgets every cached client and listing, and puts `configureClients` back to the defaults.
55
59
  - `servedWindow` — The window a local server is actually serving a model in, which its listing does not say.
56
60
  - `timeoutMs` — Zero, less, or absent means no limit, which the SDK spells as `undefined`.
57
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
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",