@cubicecho/agent-core 2.22.0 → 2.23.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
@@ -43,6 +43,7 @@ only, Node >=22.
43
43
  | `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
44
44
  | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
45
45
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
46
+ | `runtime` | `createRuntime`: a second set of those caches, with the functions that use them as methods — for a host whose tenants must not share a client pool, a latch or an event bus. `defaultRuntime` is the one the top-level functions use. |
46
47
  | `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
47
48
  | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. And what `runAgentLoop` throws, with the run as it stood: `AgentLoopError`, `ToolIterationLimit`, `AgentLoopOverflow`, and `failedRun` to read it off any of them. |
48
49
  | `catalog` | `CatalogServer`: the name-only shape `tool-loading` reads a connected server as. |
@@ -1197,8 +1198,9 @@ const result = await runAgentLoop({ config, preselect: { keywords: true }, compa
1197
1198
 
1198
1199
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
1199
1200
  capabilities, and `side-task`'s no-thinking hints. A fifth, the characters per token each model was
1200
- measured at, is keyed on the endpoint's capabilities object, so it goes with them. All four are module-level and keyed on the same
1201
- notion of an endpoint — its base URL and its API key — and the clients' key carries the request
1201
+ measured at, is keyed on the endpoint's capabilities object, so it goes with them. All four are
1202
+ held once per runtime — once per process, unless you make another, [below](#a-runtime-of-its-own)
1203
+ — and keyed on the same notion of an endpoint — its base URL and its API key — and the clients' key carries the request
1202
1204
  timeout as well, since that changes how a request is sent.
1203
1205
 
1204
1206
  **They are keyed per deployment, not per request.** What belongs in them is an endpoint an
@@ -1209,8 +1211,8 @@ bounding them assumes that, and a consumer that mints an API key per *user* brea
1209
1211
 
1210
1212
  That is bounded rather than unbounded: the client pool is a 32-entry LRU, and an evicted client
1211
1213
  costs its connection pool and nothing else, since the next request through that endpoint builds
1212
- another. But churning connection pools is not sharing them, and at that point a client of your
1213
- own, built and held per tenant, is the better answer than this.
1214
+ another. But churning connection pools is not sharing them, and at that point a runtime per
1215
+ tenant, [below](#a-runtime-of-its-own), is the better answer than this.
1214
1216
 
1215
1217
  The listings cache also remembers, for half a minute, that an endpoint did not name a model — the
1216
1218
  case where a configured name never matches anything the server serves (a llama.cpp `-a` alias, an
@@ -1284,6 +1286,57 @@ carries it in the snapshot so an imported latch keeps its real age instead of be
1284
1286
  every boot. Importing takes the older of the two ages, and a snapshot written before this field
1285
1287
  existed reads as met now.
1286
1288
 
1289
+ ### A runtime of its own
1290
+
1291
+ Everything above is the *default runtime*: one set of caches, shared by every caller in the
1292
+ process. `createRuntime` makes another, with its own clients, listings, latches, hints, measured
1293
+ ratios, event bus and hook settings, and the functions that use them as methods of the same names
1294
+ and signatures:
1295
+
1296
+ ```ts
1297
+ import { createRuntime } from "@cubicecho/agent-core";
1298
+
1299
+ const runtime = createRuntime({ clients: { maxClients: 8 }, events: { maxEvents: 200 } });
1300
+
1301
+ const run = await runtime.runAgentLoop({ config, messages, dispatch });
1302
+ for await (const event of runtime.watch(runId)) send(event);
1303
+ runtime.resetAll(); // this runtime's caches, and nobody else's
1304
+ ```
1305
+
1306
+ It is for the host the section above warns off — one that mints a key per tenant, where a shared
1307
+ pool has tenants evicting each other's clients and a shared bus makes one tenant's run id readable
1308
+ by another — and for tests, where a runtime per case needs no `resetAll` between cases and lets
1309
+ them run in parallel. A host with one deployment's worth of endpoints needs none: the top-level
1310
+ functions are the default runtime's methods, and nothing about them has changed.
1311
+
1312
+ The runtime travels by async context (`AsyncLocalStorage`), not by argument. A method runs its
1313
+ function with the runtime current, and it stays current across every `await` beneath it — so
1314
+ `runtime.runAgentLoop` uses the runtime's clients and latches all the way down, and so does a
1315
+ top-level function your code calls from inside it: an `emit` in `onEvent`, an `ask` in `dispatch`,
1316
+ a `gather` in a hook. `runtime.run(fn)` does the same for code that calls the top-level functions
1317
+ itself:
1318
+
1319
+ ```ts
1320
+ await runtime.run(async () => {
1321
+ const limit = await contextLimitFor(config); // the runtime's listing cache
1322
+ emit(runId, { kind: "output", text: "…" }); // the runtime's bus
1323
+ });
1324
+ ```
1325
+
1326
+ Outside a method or `run`, a top-level function is the default runtime's again. That is the one
1327
+ thing to keep straight: a request handler that watches or emits to a run a runtime started is not
1328
+ inside it, and calls `runtime.watch` or `runtime.emit`. What a method hands back keeps its runtime
1329
+ — the iterator from `runtime.watch`, the function from `runtime.summariser` — wherever it is read.
1330
+
1331
+ A new runtime starts from the package's defaults plus what `createRuntime` was given; it does not
1332
+ inherit what `configureClients` and the others set on the default one. Nothing needs closing: a
1333
+ runtime is garbage once nothing holds it. `defaultRuntime` is the process's own as an object, for
1334
+ code that takes a `Runtime` and should be handed the shared one.
1335
+
1336
+ The two schema caches (`sanitizeTools`, `relaxTools`) and `requestTokens`' tool-length cache stay
1337
+ process-wide. They are memoised pure functions keyed on the caller's own objects, so there is
1338
+ nothing in them for one tenant to learn about another.
1339
+
1287
1340
  ## Where the merged behaviour came from
1288
1341
 
1289
1342
  - `schema-compat` — `kanban_server`/`task_server`'s version, which strips **every** sibling of a
@@ -1,4 +1,5 @@
1
1
  import { CHARS_PER_TOKEN, requestChars, toolsChars } from "./retry.js";
2
+ import { scoped } from "./scope.js";
2
3
  /**
3
4
  * How many characters a token is worth on one model, learned from what its endpoint reports.
4
5
  *
@@ -27,7 +28,7 @@ const PLAUSIBLE = { least: 1, most: 8 };
27
28
  * `resetCapabilities` takes its readings with it. Replaced rather than cleared by `resetCalibration`,
28
29
  * since a `WeakMap` has no `clear`.
29
30
  */
30
- let readings = new WeakMap();
31
+ const readings = scoped(() => new WeakMap());
31
32
  /**
32
33
  * The characters per token to size a request to this model with, `CHARS_PER_TOKEN` until a turn
33
34
  * has reported one.
@@ -43,7 +44,7 @@ let readings = new WeakMap();
43
44
  * @param model The name the endpoint knows the model as, as it goes in the body.
44
45
  */
45
46
  export function charsPerTokenFor(supports, model) {
46
- const known = readings.get(supports)?.get(model);
47
+ const known = readings().get(supports)?.get(model);
47
48
  return known?.length ? Math.max(...known) : CHARS_PER_TOKEN;
48
49
  }
49
50
  /** Whether any message carries a part whose tokens its characters do not count. */
@@ -69,10 +70,10 @@ export function calibrate(supports, body, promptTokens) {
69
70
  if (ratio < PLAUSIBLE.least || ratio > PLAUSIBLE.most) {
70
71
  return charsPerTokenFor(supports, body.model);
71
72
  }
72
- let models = readings.get(supports);
73
+ let models = readings().get(supports);
73
74
  if (!models) {
74
75
  models = new Map();
75
- readings.set(supports, models);
76
+ readings().set(supports, models);
76
77
  }
77
78
  const known = models.get(body.model) ?? [];
78
79
  known.push(ratio);
@@ -83,5 +84,5 @@ export function calibrate(supports, body, promptTokens) {
83
84
  }
84
85
  /** Forgets every reading, so the next request is sized at `CHARS_PER_TOKEN` again. */
85
86
  export function resetCalibration() {
86
- readings = new WeakMap();
87
+ readings.reset();
87
88
  }
@@ -1,6 +1,7 @@
1
1
  import { endpointId } from "./client.js";
2
2
  import { errorMessage } from "./errors.js";
3
3
  import { isGrammarError } from "./schema-compat.js";
4
+ import { scoped } from "./scope.js";
4
5
  /**
5
6
  * What each endpoint cannot do, remembered for the life of the process.
6
7
  *
@@ -19,7 +20,7 @@ import { isGrammarError } from "./schema-compat.js";
19
20
  * behind it, including the `models` map underneath, which is the level where two keys through
20
21
  * one host are most likely to differ at all.
21
22
  */
22
- const capabilities = new Map();
23
+ const capabilities = scoped(() => new Map());
23
24
  /**
24
25
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
25
26
  * latches off stays off.
@@ -64,16 +65,16 @@ export function modelCapabilitiesFor(supports, model) {
64
65
  return known;
65
66
  }
66
67
  /** Every endpoint's capabilities by `endpointId`, the live objects, for `exportCapabilities`. */
67
- export const knownCapabilities = () => capabilities;
68
+ export const knownCapabilities = () => capabilities();
68
69
  /**
69
70
  * The capabilities of the endpoint with this `endpointId`, created optimistic if unseen — the way
70
71
  * `importCapabilities` reaches an endpoint it has only a digest for.
71
72
  */
72
73
  export function capabilitiesById(id) {
73
- let known = capabilities.get(id);
74
+ let known = capabilities().get(id);
74
75
  if (!known) {
75
76
  known = { strictSchemas: true, usageInStream: true, models: new Map(), since: Date.now() };
76
- capabilities.set(id, known);
77
+ capabilities().set(id, known);
77
78
  }
78
79
  return known;
79
80
  }
@@ -92,11 +93,11 @@ export function capabilitiesById(id) {
92
93
  */
93
94
  export function resetCapabilities(endpoint) {
94
95
  if (!endpoint) {
95
- const held = capabilities.size > 0;
96
- capabilities.clear();
96
+ const held = capabilities().size > 0;
97
+ capabilities().clear();
97
98
  return held;
98
99
  }
99
- return capabilities.delete(endpointId(endpoint));
100
+ return capabilities().delete(endpointId(endpoint));
100
101
  }
101
102
  /**
102
103
  * Forgets every endpoint whose entry is older than this, so the next request finds out again.
@@ -117,10 +118,10 @@ export function resetCapabilities(endpoint) {
117
118
  */
118
119
  export function expireCapabilities(maxAgeMs, now = Date.now()) {
119
120
  let dropped = 0;
120
- for (const [id, known] of capabilities) {
121
+ for (const [id, known] of capabilities()) {
121
122
  if (now - known.since < maxAgeMs)
122
123
  continue;
123
- capabilities.delete(id);
124
+ capabilities().delete(id);
124
125
  dropped++;
125
126
  }
126
127
  return dropped;
package/dist/client.d.ts CHANGED
@@ -27,10 +27,11 @@ export interface ClientPoolOptions {
27
27
  * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
28
28
  * deployments these defaults were chosen for.
29
29
  *
30
- * Module-level for the same reason `configureEvents` is: the pool is one module-level thing, and
31
- * its size is a deployment's setting, said once at startup. A multi-tenant host keying on a key
32
- * per user raises `maxClients` so its tenants stop evicting each other's connection pools; a dev
33
- * box shortens `listingMissMs` so a model it has just pulled is seen sooner.
30
+ * One setting per runtime for the same reason `configureEvents` is: the pool is one thing per
31
+ * runtime, and its size is a deployment's setting, said once at startup. A multi-tenant host
32
+ * keying on a key per user raises `maxClients` so its tenants stop evicting each other's
33
+ * connection pools, or gives each tenant a `createRuntime` of its own; a dev box shortens
34
+ * `listingMissMs` so a model it has just pulled is seen sooner.
34
35
  *
35
36
  * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
36
37
  * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import OpenAI from "openai";
3
+ import { scoped } from "./scope.js";
3
4
  /**
4
5
  * The SDK insists on a non-empty key even where the server will not look at it. This is what it
5
6
  * gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
@@ -31,7 +32,7 @@ export const timeoutMs = (config) => {
31
32
  * Bounded, because a cache is only a cache while its keys are. Insertion order is the whole of
32
33
  * the eviction: a `Map` iterates oldest-first, and `getClient` re-inserts on a hit.
33
34
  */
34
- const clients = new Map();
35
+ const clients = scoped(() => new Map());
35
36
  /**
36
37
  * How many endpoints' clients are kept at once, until `configureClients` moves it.
37
38
  *
@@ -55,7 +56,7 @@ const CLIENT_DEFAULTS = {
55
56
  listingMissMs: LISTING_MISS_MS,
56
57
  };
57
58
  /** What is in force now. Read where it is used, so a change applies from the next call. */
58
- let poolLimits = { ...CLIENT_DEFAULTS };
59
+ const poolLimits = scoped(() => ({ ...CLIENT_DEFAULTS }));
59
60
  /**
60
61
  * Drops the least recently asked-for clients until the pool fits. Nothing is closed on the way
61
62
  * out: the SDK holds no handle a caller can release, and an evicted client is garbage once
@@ -63,20 +64,21 @@ let poolLimits = { ...CLIENT_DEFAULTS };
63
64
  * of the eviction.
64
65
  */
65
66
  const evict = () => {
66
- for (const oldest of clients.keys()) {
67
- if (clients.size <= poolLimits.maxClients)
67
+ for (const oldest of clients().keys()) {
68
+ if (clients().size <= poolLimits().maxClients)
68
69
  break;
69
- clients.delete(oldest);
70
+ clients().delete(oldest);
70
71
  }
71
72
  };
72
73
  /**
73
74
  * Changes what the client pool is held to, for a process whose endpoints are not shaped like the
74
75
  * deployments these defaults were chosen for.
75
76
  *
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.
77
+ * One setting per runtime for the same reason `configureEvents` is: the pool is one thing per
78
+ * runtime, and its size is a deployment's setting, said once at startup. A multi-tenant host
79
+ * keying on a key per user raises `maxClients` so its tenants stop evicting each other's
80
+ * connection pools, or gives each tenant a `createRuntime` of its own; a dev box shortens
81
+ * `listingMissMs` so a model it has just pulled is seen sooner.
80
82
  *
81
83
  * A `maxClients` below the pool's current size evicts down to it at once, least recently asked
82
84
  * for first, as the next `getClient` would have. A shorter `listingMissMs` applies to misses
@@ -91,11 +93,11 @@ const evict = () => {
91
93
  export function configureClients(options = {}) {
92
94
  for (const [name, value] of Object.entries(options)) {
93
95
  if (typeof value === "number" && value > 0) {
94
- poolLimits[name] = value;
96
+ poolLimits()[name] = value;
95
97
  }
96
98
  }
97
99
  evict();
98
- return { ...poolLimits };
100
+ return { ...poolLimits() };
99
101
  }
100
102
  /** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
101
103
  export const FIRST_TOKEN_FACTOR = 5;
@@ -132,16 +134,16 @@ export function getClient(config) {
132
134
  // Stringified rather than joined on a separator: no character is impossible in a URL or a
133
135
  // key, and two different endpoints must never resolve to the same cached client.
134
136
  const key = JSON.stringify([config.baseUrl, apiKey, timeout]);
135
- const existing = clients.get(key);
137
+ const existing = clients().get(key);
136
138
  if (existing) {
137
139
  // Re-inserted so it counts as the youngest. A `Map` keeps insertion order and hands the
138
140
  // oldest key over first, which is the whole of the LRU below.
139
- clients.delete(key);
140
- clients.set(key, existing);
141
+ clients().delete(key);
142
+ clients().set(key, existing);
141
143
  return existing;
142
144
  }
143
145
  const client = new OpenAI({ baseURL: config.baseUrl, apiKey, timeout, maxRetries: 0 });
144
- clients.set(key, client);
146
+ clients().set(key, client);
145
147
  evict();
146
148
  return client;
147
149
  }
@@ -197,14 +199,14 @@ function contextLengthOf(model) {
197
199
  * It is only ever a cache of something asked for anyway, and a listing that fails leaves
198
200
  * whatever was there rather than emptying it.
199
201
  */
200
- const listings = new Map();
202
+ const listings = scoped(() => new Map());
201
203
  /**
202
204
  * The models in each endpoint's last listing whose window is only the trained one, by id.
203
205
  *
204
206
  * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
205
207
  * it has always been. Replaced with each listing that lands, and left alone by one that fails.
206
208
  */
207
- const trainedOnly = new Map();
209
+ const trainedOnly = scoped(() => new Map());
208
210
  /**
209
211
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
210
212
  *
@@ -219,7 +221,7 @@ const trainedOnly = new Map();
219
221
  * picked up within the minute instead of at the next restart. Bounded by the (endpoint, model)
220
222
  * pairs actually asked about, which is the bound the listings themselves have.
221
223
  */
222
- const misses = new Map();
224
+ const misses = scoped(() => new Map());
223
225
  /**
224
226
  * What counts as one endpoint, everywhere in this package that has to remember something about
225
227
  * one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
@@ -257,9 +259,9 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
257
259
  * A model's entry stays until `resetClients`, like a listing; a server that answered without one
258
260
  * is asked again after `listingMissMs`, like a listing that did not name the model.
259
261
  */
260
- const served = new Map();
262
+ const served = scoped(() => new Map());
261
263
  /** Endpoints with neither native route, which are not asked for either again. */
262
- const unserved = new Set();
264
+ const unserved = scoped(() => new Set());
263
265
  /**
264
266
  * Endpoints whose `/api/v0/models` answered with an error, and when.
265
267
  *
@@ -271,11 +273,11 @@ const unserved = new Set();
271
273
  * Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
272
274
  * window is never `unserved`, and without this its missing route was asked again with every miss.
273
275
  */
274
- const refusals = new Map();
276
+ const refusals = scoped(() => new Map());
275
277
  /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
276
278
  const refused = (endpoint) => {
277
- const refusal = refusals.get(endpoint);
278
- return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
279
+ const refusal = refusals().get(endpoint);
280
+ return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits().listingMissMs));
279
281
  };
280
282
  /** How long a probe may take before the window is taken from the listing instead. */
281
283
  const PROBE_TIMEOUT_MS = 10_000;
@@ -324,11 +326,11 @@ async function probe(config, path) {
324
326
  */
325
327
  export async function servedWindow(config) {
326
328
  const endpoint = endpointKey(config);
327
- if (unserved.has(endpoint))
329
+ if (unserved().has(endpoint))
328
330
  return 0;
329
331
  const key = JSON.stringify([endpoint, config.model]);
330
- const known = served.get(key);
331
- if (known && (known.window > 0 || Date.now() - known.at < poolLimits.listingMissMs))
332
+ const known = served().get(key);
333
+ if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs))
332
334
  return known.window;
333
335
  let window = 0;
334
336
  try {
@@ -341,17 +343,17 @@ export async function servedWindow(config) {
341
343
  if (!refused(endpoint)) {
342
344
  const lmstudio = await probe(config, "/api/v0/models");
343
345
  if (lmstudio.failed)
344
- refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
346
+ refusals().set(endpoint, { at: Date.now(), gone: lmstudio.missing });
345
347
  else
346
- refusals.delete(endpoint);
348
+ refusals().delete(endpoint);
347
349
  const { data } = (lmstudio.body ?? {});
348
350
  const entry = Array.isArray(data)
349
351
  ? data.find((model) => model?.id === config.model)
350
352
  : undefined;
351
353
  window = positive(entry?.loaded_context_length);
352
354
  }
353
- if (props.missing && refusals.get(endpoint)?.gone) {
354
- unserved.add(endpoint);
355
+ if (props.missing && refusals().get(endpoint)?.gone) {
356
+ unserved().add(endpoint);
355
357
  return 0;
356
358
  }
357
359
  }
@@ -359,7 +361,7 @@ export async function servedWindow(config) {
359
361
  catch {
360
362
  return 0;
361
363
  }
362
- served.set(key, { window, at: Date.now() });
364
+ served().set(key, { window, at: Date.now() });
363
365
  return window;
364
366
  }
365
367
  /**
@@ -379,8 +381,8 @@ export async function listModels(config) {
379
381
  })
380
382
  .sort((a, b) => a.id.localeCompare(b.id));
381
383
  const key = endpointKey(config);
382
- listings.set(key, models);
383
- trainedOnly.set(key, trained);
384
+ listings().set(key, models);
385
+ trainedOnly().set(key, trained);
384
386
  return models;
385
387
  }
386
388
  /**
@@ -411,7 +413,9 @@ export async function contextLimitFor(config, declared = 0) {
411
413
  if (declared > 0)
412
414
  return declared;
413
415
  const key = endpointKey(config);
414
- const listed = () => listings.get(key)?.find((model) => model.id === config.model);
416
+ const listed = () => listings()
417
+ .get(key)
418
+ ?.find((model) => model.id === config.model);
415
419
  // The listing is asked for again when it does not name this model, rather than only when
416
420
  // there is no listing at all. Models arrive after a process starts — an `ollama pull` on a
417
421
  // box that has been up a week, a worker added to a router, a name the operator has only just
@@ -421,11 +425,11 @@ export async function contextLimitFor(config, declared = 0) {
421
425
  // the window instead of under it and fails against the endpoint's own refusal.
422
426
  if (!listed()) {
423
427
  const missKey = JSON.stringify([key, config.model]);
424
- const asked = misses.get(missKey);
428
+ const asked = misses().get(missKey);
425
429
  // Asked again, but not on every call. A model that is never coming answers the same zero
426
430
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
427
431
  // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
428
- if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
432
+ if (asked === undefined || Date.now() - asked >= poolLimits().listingMissMs) {
429
433
  // A failure is not remembered: an endpoint that was down when the last run started is not
430
434
  // an endpoint with no models, and a window nobody could ask about is not a failed run.
431
435
  const answered = await listModels(config).then(() => true, () => false);
@@ -435,9 +439,9 @@ export async function contextLimitFor(config, declared = 0) {
435
439
  // half a minute.
436
440
  if (answered) {
437
441
  if (listed())
438
- misses.delete(missKey);
442
+ misses().delete(missKey);
439
443
  else
440
- misses.set(missKey, Date.now());
444
+ misses().set(missKey, Date.now());
441
445
  }
442
446
  }
443
447
  }
@@ -445,7 +449,7 @@ export async function contextLimitFor(config, declared = 0) {
445
449
  const fromListing = entry?.contextLength ?? 0;
446
450
  // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
447
451
  // 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))
452
+ if (fromListing > 0 && !trainedOnly().get(key)?.has(config.model))
449
453
  return fromListing;
450
454
  const window = await servedWindow(config);
451
455
  return window > 0 ? window : fromListing;
@@ -455,12 +459,12 @@ export async function contextLimitFor(config, declared = 0) {
455
459
  * tests, and for a settings change under test.
456
460
  */
457
461
  export function resetClients() {
458
- poolLimits = { ...CLIENT_DEFAULTS };
459
- clients.clear();
460
- listings.clear();
461
- misses.clear();
462
- trainedOnly.clear();
463
- served.clear();
464
- unserved.clear();
465
- refusals.clear();
462
+ poolLimits.reset();
463
+ clients().clear();
464
+ listings().clear();
465
+ misses().clear();
466
+ trainedOnly().clear();
467
+ served().clear();
468
+ unserved().clear();
469
+ refusals().clear();
466
470
  }
package/dist/events.d.ts CHANGED
@@ -50,8 +50,10 @@ export interface EventBusOptions {
50
50
  * Changes what the bus keeps, for a process whose runs are not shaped like the ones these
51
51
  * defaults were chosen for.
52
52
  *
53
- * The bus is one module-level thing rather than an object a caller holds, so this is too: it is
53
+ * The bus is one thing per runtime rather than an object a caller holds, so this is too: it is
54
54
  * a deployment's setting, said once at startup, and not something to move around under a run.
55
+ * Called at the top level that is the process's bus; a runtime made by `createRuntime` has its
56
+ * own, set through its options or its own `configureEvents`.
55
57
  * What it costs is memory against how much of a run a late or slow watcher can still read —
56
58
  * a server with hundreds of concurrent runs wants a smaller backlog, and one whose tool calls
57
59
  * take an hour wants a longer `retainUnendedMs` than the thirty minutes assumed here.
package/dist/events.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { scoped } from "./scope.js";
1
2
  import { LOAD_TOOLS } from "./tool-loading.js";
2
3
  /** The numbers a run of the shape this bus was written for wants. */
3
4
  const DEFAULTS = {
@@ -6,14 +7,15 @@ const DEFAULTS = {
6
7
  retainMs: 60_000,
7
8
  retainUnendedMs: 30 * 60_000,
8
9
  };
9
- /** What is in force now. Read where it is used, so a change applies from the next event. */
10
- let limits = { ...DEFAULTS };
10
+ const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweeping: null }));
11
11
  /**
12
12
  * Changes what the bus keeps, for a process whose runs are not shaped like the ones these
13
13
  * defaults were chosen for.
14
14
  *
15
- * The bus is one module-level thing rather than an object a caller holds, so this is too: it is
15
+ * The bus is one thing per runtime rather than an object a caller holds, so this is too: it is
16
16
  * a deployment's setting, said once at startup, and not something to move around under a run.
17
+ * Called at the top level that is the process's bus; a runtime made by `createRuntime` has its
18
+ * own, set through its options or its own `configureEvents`.
17
19
  * What it costs is memory against how much of a run a late or slow watcher can still read —
18
20
  * a server with hundreds of concurrent runs wants a smaller backlog, and one whose tool calls
19
21
  * take an hour wants a longer `retainUnendedMs` than the thirty minutes assumed here.
@@ -27,14 +29,14 @@ let limits = { ...DEFAULTS };
27
29
  * @returns Everything in force afterwards, including what this call did not change.
28
30
  */
29
31
  export function configureEvents(options = {}) {
32
+ const { limits } = bus();
30
33
  for (const [name, value] of Object.entries(options)) {
31
34
  if (typeof value === "number" && value > 0)
32
35
  limits[name] = value;
33
36
  }
34
37
  return { ...limits };
35
38
  }
36
- const streams = new Map();
37
- const streamFor = (runId) => {
39
+ const streamFor = ({ streams }, runId) => {
38
40
  const existing = streams.get(runId);
39
41
  if (existing)
40
42
  return existing;
@@ -60,9 +62,9 @@ const streamFor = (runId) => {
60
62
  *
61
63
  * One timer for the whole map, rescheduled only while there is something in it to expire.
62
64
  */
63
- let sweeping = null;
64
- function sweep() {
65
- sweeping = null;
65
+ function sweep(held) {
66
+ const { limits, streams } = held;
67
+ held.sweeping = null;
66
68
  const now = Date.now();
67
69
  for (const [runId, stream] of streams) {
68
70
  if (stream.listeners.size)
@@ -70,13 +72,13 @@ function sweep() {
70
72
  if (stream.touched <= now - (stream.ended ? limits.retainMs : limits.retainUnendedMs))
71
73
  streams.delete(runId);
72
74
  }
73
- scheduleSweep();
75
+ scheduleSweep(held);
74
76
  }
75
- function scheduleSweep() {
76
- if (sweeping || streams.size === 0)
77
+ function scheduleSweep(held) {
78
+ if (held.sweeping || held.streams.size === 0)
77
79
  return;
78
- sweeping = setTimeout(sweep, limits.retainMs);
79
- sweeping.unref?.();
80
+ held.sweeping = setTimeout(() => sweep(held), held.limits.retainMs);
81
+ held.sweeping.unref?.();
80
82
  }
81
83
  /**
82
84
  * Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
@@ -86,6 +88,7 @@ function scheduleSweep() {
86
88
  * @param runId The run to forget. An id nothing was emitted under is ignored.
87
89
  */
88
90
  export function endRun(runId) {
91
+ const { streams } = bus();
89
92
  const stream = streams.get(runId);
90
93
  if (!stream)
91
94
  return;
@@ -106,7 +109,9 @@ export function endRun(runId) {
106
109
  * @param input The event. `kind` is required; `runId` and `seq` are not a caller's to set.
107
110
  */
108
111
  export function emit(runId, input) {
109
- const stream = streamFor(runId);
112
+ const held = bus();
113
+ const { limits } = held;
114
+ const stream = streamFor(held, runId);
110
115
  // One clock read, used for both the event and the sweep's bookkeeping.
111
116
  const at = Date.now();
112
117
  const event = {
@@ -130,7 +135,7 @@ export function emit(runId, input) {
130
135
  // then dropped: a finished run's record is the row, not this.
131
136
  if (event.kind === "done")
132
137
  stream.ended = true;
133
- scheduleSweep();
138
+ scheduleSweep(held);
134
139
  for (const listener of stream.listeners) {
135
140
  // The guarantee above is the point of this: a listener that throws must not take out the
136
141
  // emitter, the listeners after it, or the bookkeeping already done above.
@@ -155,8 +160,14 @@ export function emit(runId, input) {
155
160
  * function it is suspended at an `await` rather than at a `yield`, and a `return()` there is
156
161
  * queued behind a promise only the next event can settle. An abort resolves that promise itself.
157
162
  */
158
- export async function* watch(runId, signal) {
159
- const stream = streamFor(runId);
163
+ export function watch(runId, signal) {
164
+ // Read here, not in the generator: its body runs when the first event is asked for, and by
165
+ // then the caller may be outside the runtime whose run this is.
166
+ return watching(bus(), runId, signal);
167
+ }
168
+ async function* watching(held, runId, signal) {
169
+ const { limits, streams } = held;
170
+ const stream = streamFor(held, runId);
160
171
  // A cursor rather than `shift()`. Draining a backlog an event at a time off the front of an
161
172
  // array is a copy of the whole array per event, which on the ten-thousand-delta run this bus
162
173
  // is built for is the one quadratic left in the file. The prefix behind the cursor is dropped
@@ -272,7 +283,7 @@ export async function* watch(runId, signal) {
272
283
  *
273
284
  * @param runId The run to read. An unknown or already-swept run gives an empty array.
274
285
  */
275
- export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
286
+ export const history = (runId) => [...(bus().streams.get(runId)?.events ?? [])];
276
287
  /**
277
288
  * Test seam: forget every run, so one test's events cannot be read by the next.
278
289
  *
@@ -286,11 +297,13 @@ export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
286
297
  * teardown configures again, which is the same line it already wrote once.
287
298
  */
288
299
  export const resetEvents = () => {
289
- streams.clear();
290
- if (sweeping)
291
- clearTimeout(sweeping);
292
- sweeping = null;
293
- limits = { ...DEFAULTS };
300
+ const held = bus();
301
+ held.streams.clear();
302
+ if (held.sweeping)
303
+ clearTimeout(held.sweeping);
304
+ held.sweeping = null;
305
+ // In place, so a watcher still open reads the defaults too and not the caps it began under.
306
+ Object.assign(held.limits, DEFAULTS);
294
307
  };
295
308
  /**
296
309
  * Consecutive tokens of one kind are one thing being said, not hundreds of things.
package/dist/hooks.d.ts CHANGED
@@ -160,8 +160,8 @@ export interface HookOptions {
160
160
  * Changes what hooks are held to, for a process whose windows are not the size these defaults
161
161
  * were chosen for, or whose host wants its own name above the context.
162
162
  *
163
- * Module-level for the same reason `configureEvents` is: a budget and a preface are a deployment's
164
- * settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
163
+ * One setting per runtime for the same reason `configureEvents` is: a budget and a preface are a
164
+ * deployment's settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
165
165
  * window can afford more recall than an 8k one — passes `maxTokens` to `gather` instead, and a
166
166
  * `preface` passed to `withContext` or `runAgentLoop`'s hooks wins over this one the same way.
167
167
  *
package/dist/hooks.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { errorMessage } from "./errors.js";
3
+ import { scoped } from "./scope.js";
3
4
  import { estimateTokens } from "./tokens.js";
4
5
  /** Every event a hook can be bound to, in the order a session meets them. */
5
6
  export const HOOK_EVENTS = [
@@ -36,13 +37,13 @@ const HOOK_DEFAULTS = {
36
37
  preface: HOOK_PREFACE,
37
38
  };
38
39
  /** What is in force now. Read where it is used, so a change applies from the next request. */
39
- let hookSettings = { ...HOOK_DEFAULTS };
40
+ const hookSettings = scoped(() => ({ ...HOOK_DEFAULTS }));
40
41
  /**
41
42
  * Changes what hooks are held to, for a process whose windows are not the size these defaults
42
43
  * were chosen for, or whose host wants its own name above the context.
43
44
  *
44
- * Module-level for the same reason `configureEvents` is: a budget and a preface are a deployment's
45
- * settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
45
+ * One setting per runtime for the same reason `configureEvents` is: a budget and a preface are a
46
+ * deployment's settings, said once at startup. A caller that sizes the budget per model or per agent — a 128k
46
47
  * window can afford more recall than an 8k one — passes `maxTokens` to `gather` instead, and a
47
48
  * `preface` passed to `withContext` or `runAgentLoop`'s hooks wins over this one the same way.
48
49
  *
@@ -55,11 +56,11 @@ let hookSettings = { ...HOOK_DEFAULTS };
55
56
  export function configureHooks(options = {}) {
56
57
  const { contextTokens, preface } = options;
57
58
  if (typeof contextTokens === "number" && contextTokens > 0) {
58
- hookSettings.contextTokens = contextTokens;
59
+ hookSettings().contextTokens = contextTokens;
59
60
  }
60
61
  if (typeof preface === "string")
61
- hookSettings.preface = preface;
62
- return { ...hookSettings };
62
+ hookSettings().preface = preface;
63
+ return { ...hookSettings() };
63
64
  }
64
65
  /**
65
66
  * Test seam: puts `configureHooks` back to the defaults, so one test's budget or preface is not the
@@ -67,14 +68,14 @@ export function configureHooks(options = {}) {
67
68
  * `resetAll` calls it.
68
69
  */
69
70
  export const resetHooks = () => {
70
- hookSettings = { ...HOOK_DEFAULTS };
71
+ hookSettings.reset();
71
72
  };
72
73
  /**
73
74
  * The budget a call is held to: its own when it gave a usable one, the process's otherwise. The
74
75
  * same rule `configureHooks` applies, so a `0` threaded through for "no opinion" does not quietly
75
76
  * turn every hook's context off.
76
77
  */
77
- const budget = (given) => typeof given === "number" && given > 0 ? given : hookSettings.contextTokens;
78
+ const budget = (given) => typeof given === "number" && given > 0 ? given : hookSettings().contextTokens;
78
79
  const attribute = (text) => text.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;");
79
80
  /**
80
81
  * Builds the context a set of outcomes adds and the notes that go with it.
@@ -143,7 +144,7 @@ export function assembleContext(outcomes, maxTokens) {
143
144
  * unless something moved it. Empty says nothing, rather than leaving a blank line where it was.
144
145
  * @returns `history` when there was nothing to add or nowhere to add it, otherwise a new array.
145
146
  */
146
- export function withContext(history, index, context, preface = hookSettings.preface) {
147
+ export function withContext(history, index, context, preface = hookSettings().preface) {
147
148
  const message = history[index];
148
149
  if (!context || message?.role !== "user")
149
150
  return history;
package/dist/index.d.ts CHANGED
@@ -24,6 +24,7 @@ export { estimateFrom, type LedgerEntry, type LedgerEstimateOptions, type Ledger
24
24
  export { resetAll } from "./reset.ts";
25
25
  export { backoffMs, CHARS_PER_TOKEN, type ContextBreakdown, type ContextBreakdownOptions, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, type TokenEstimateOptions, toolsChars, } from "./retry.ts";
26
26
  export { type RunTurnOptions, runTurn } from "./run-turn.ts";
27
+ export { createRuntime, defaultRuntime, type Runtime, type RuntimeOptions } from "./runtime.ts";
27
28
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
28
29
  export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
29
30
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ export { estimateFrom, rebaseLedger, recordRequest, tokensBetween, } from "./led
22
22
  export { resetAll } from "./reset.js";
23
23
  export { backoffMs, CHARS_PER_TOKEN, ContextOverflow, compact, contextChars, contextTokens, EndpointSilent, isModelLoading, isOverflow, isTransient, LOADING_POLL_MS, LOADING_TIMEOUT_MS, messageTokens, requestChars, requestTokens, SMALLEST_LIKELY_WINDOW, sleep, toolsChars, } from "./retry.js";
24
24
  export { runTurn } from "./run-turn.js";
25
+ export { createRuntime, defaultRuntime } from "./runtime.js";
25
26
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
26
27
  export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
27
28
  export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
package/dist/reset.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Forgets everything this package remembers between calls.
3
3
  *
4
- * Six modules here keep state for the life of the process, each for a good reason and each
5
- * with its own seam: the pooled clients and their model listings, the endpoints that turned
4
+ * Six modules here keep state for the life of the runtime — the process's, unless this is called
5
+ * as a method of one `createRuntime` made, which clears that one alone — each for a good reason
6
+ * and each with its own seam: the pooled clients and their model listings, the endpoints that turned
6
7
  * out not to take `stream_options` or a grammar, each model's measured characters per token,
7
8
  * the models that refused the no-thinking hints, the event bus, and the hooks' configured
8
9
  * budget and preface. `resetClients`, `resetCapabilities`, `resetCalibration`, `resetHints`,
@@ -18,5 +19,8 @@
18
19
  *
19
20
  * It is also the seam that does not need finding again. A seventh module with a cache is a seventh
20
21
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
22
+ *
23
+ * A test that builds a `createRuntime` per case has nothing to tear down, and is the better
24
+ * answer where cases run in parallel: this clears state every concurrent caller is sharing.
21
25
  */
22
26
  export declare function resetAll(): void;
package/dist/reset.js CHANGED
@@ -7,8 +7,9 @@ import { resetHints } from "./side-task.js";
7
7
  /**
8
8
  * Forgets everything this package remembers between calls.
9
9
  *
10
- * Six modules here keep state for the life of the process, each for a good reason and each
11
- * with its own seam: the pooled clients and their model listings, the endpoints that turned
10
+ * Six modules here keep state for the life of the runtime — the process's, unless this is called
11
+ * as a method of one `createRuntime` made, which clears that one alone — each for a good reason
12
+ * and each with its own seam: the pooled clients and their model listings, the endpoints that turned
12
13
  * out not to take `stream_options` or a grammar, each model's measured characters per token,
13
14
  * the models that refused the no-thinking hints, the event bus, and the hooks' configured
14
15
  * budget and preface. `resetClients`, `resetCapabilities`, `resetCalibration`, `resetHints`,
@@ -24,6 +25,9 @@ import { resetHints } from "./side-task.js";
24
25
  *
25
26
  * It is also the seam that does not need finding again. A seventh module with a cache is a seventh
26
27
  * line here, rather than an edit to the teardown of three consumers who will not all notice.
28
+ *
29
+ * A test that builds a `createRuntime` per case has nothing to tear down, and is the better
30
+ * answer where cases run in parallel: this clears state every concurrent caller is sharing.
27
31
  */
28
32
  export function resetAll() {
29
33
  resetClients();
@@ -0,0 +1,120 @@
1
+ import { preselect, runAgentLoop } from "./agent-loop.ts";
2
+ import { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
3
+ import { capabilitiesFor, expireCapabilities, resetCapabilities } from "./capabilities.ts";
4
+ import { type ClientPoolOptions, configureClients, contextLimitFor, getClient, listModels, resetClients, servedWindow } from "./client.ts";
5
+ import { compactTranscript, runCompaction, summariser } from "./compaction.ts";
6
+ import { continueTurn } from "./continuation.ts";
7
+ import { configureEvents, type EventBusOptions, emit, endRun, history, resetEvents, watch } from "./events.ts";
8
+ import { assembleContext, configureHooks, consult, gather, type HookOptions, notify, resetHooks, withContext } from "./hooks.ts";
9
+ import { resetAll } from "./reset.ts";
10
+ import { runTurn } from "./run-turn.ts";
11
+ import { ask, askJson, resetHints, tryAsk } from "./side-task.ts";
12
+ import { exportCapabilities, importCapabilities } from "./snapshot.ts";
13
+ /**
14
+ * Every exported function that reads or writes what a runtime remembers, itself or through what
15
+ * it calls. The rest of the package is pure, and is the same function whichever runtime it is
16
+ * called beside.
17
+ */
18
+ declare const STATEFUL: {
19
+ runAgentLoop: typeof runAgentLoop;
20
+ preselect: typeof preselect;
21
+ runTurn: typeof runTurn;
22
+ continueTurn: typeof continueTurn;
23
+ ask: typeof ask;
24
+ askJson: typeof askJson;
25
+ tryAsk: typeof tryAsk;
26
+ summariser: typeof summariser;
27
+ runCompaction: typeof runCompaction;
28
+ compactTranscript: typeof compactTranscript;
29
+ getClient: typeof getClient;
30
+ listModels: typeof listModels;
31
+ contextLimitFor: typeof contextLimitFor;
32
+ servedWindow: typeof servedWindow;
33
+ configureClients: typeof configureClients;
34
+ capabilitiesFor: typeof capabilitiesFor;
35
+ expireCapabilities: typeof expireCapabilities;
36
+ exportCapabilities: typeof exportCapabilities;
37
+ importCapabilities: typeof importCapabilities;
38
+ calibrate: typeof calibrate;
39
+ charsPerTokenFor: typeof charsPerTokenFor;
40
+ emit: typeof emit;
41
+ endRun: typeof endRun;
42
+ history: typeof history;
43
+ watch: typeof watch;
44
+ configureEvents: typeof configureEvents;
45
+ configureHooks: typeof configureHooks;
46
+ gather: typeof gather;
47
+ notify: typeof notify;
48
+ consult: typeof consult;
49
+ assembleContext: typeof assembleContext;
50
+ withContext: typeof withContext;
51
+ resetClients: typeof resetClients;
52
+ resetCapabilities: typeof resetCapabilities;
53
+ resetCalibration: typeof resetCalibration;
54
+ resetHints: typeof resetHints;
55
+ resetEvents: typeof resetEvents;
56
+ resetHooks: typeof resetHooks;
57
+ resetAll: typeof resetAll;
58
+ };
59
+ /** What a runtime starts out configured with. Every field optional; see `createRuntime`. */
60
+ export interface RuntimeOptions {
61
+ /** The client pool's bounds, as `configureClients` takes them. */
62
+ clients?: ClientPoolOptions;
63
+ /** The event bus's bounds, as `configureEvents` takes them. */
64
+ events?: EventBusOptions;
65
+ /** The hooks' budget and preface, as `configureHooks` takes them. */
66
+ hooks?: HookOptions;
67
+ }
68
+ /**
69
+ * One set of the package's caches, with the functions that use them as methods.
70
+ *
71
+ * Each method is the top-level function of the same name, with the same signature and doc
72
+ * comment, run against this runtime's state instead of the process's: its pooled clients and
73
+ * model listings, latched capabilities, measured characters per token, no-thinking hints, event
74
+ * bus, and hook settings. `resetAll` and the six narrower resets clear this runtime alone.
75
+ */
76
+ export type Runtime = typeof STATEFUL & {
77
+ /**
78
+ * Runs `fn` with this runtime current, so every top-level function it calls — directly, after
79
+ * an `await`, or from a timer it sets — uses this runtime's state. What the methods do, for
80
+ * code that calls the top-level functions itself.
81
+ *
82
+ * It does not reach a callback that outlives `fn` and is called from elsewhere — a generator
83
+ * resumed later, a function handed back and called by someone else. Call a method there.
84
+ *
85
+ * @param fn What to run. Its return value, or what it throws, is handed straight back.
86
+ */
87
+ run<T>(fn: () => T): T;
88
+ };
89
+ /**
90
+ * A runtime of its own: separate clients, listings, capability latches, hints, event bus and hook
91
+ * settings from the process's and from every other runtime's.
92
+ *
93
+ * For a host that must not share them — one that mints a key per tenant, where a shared pool has
94
+ * tenants evicting each other's clients and a shared bus has one tenant's run id readable by
95
+ * another — and for tests, where a runtime per case needs no `resetAll` between them and can run
96
+ * in parallel. A host with one deployment's worth of endpoints does not need one: the top-level
97
+ * functions are the default runtime's methods.
98
+ *
99
+ * The runtime is carried by async context rather than by argument, so `runAgentLoop` called as a
100
+ * method uses this runtime all the way down, and so does a top-level function the host calls from
101
+ * inside it — from `dispatch`, a hook, `onEvent`. Outside a method or `run`, a top-level function
102
+ * is the default runtime's again; a request handler that emits to a run a runtime started calls
103
+ * `runtime.emit`.
104
+ *
105
+ * Nothing needs closing. A runtime is garbage once nothing holds it; its one timer, the event
106
+ * bus's sweep, is unreferenced and stops rescheduling when its last run has been swept.
107
+ *
108
+ * @param options What to configure it with before first use. A part left out keeps the
109
+ * defaults — not the default runtime's settings, which a new runtime does not inherit.
110
+ */
111
+ export declare function createRuntime(options?: RuntimeOptions): Runtime;
112
+ /**
113
+ * The runtime the top-level functions use when no other is running.
114
+ *
115
+ * `defaultRuntime.getClient(endpoint)` and `getClient(endpoint)` are the same call outside any
116
+ * runtime. Inside one they differ: the top-level function follows the runtime that is running,
117
+ * and this always reaches the process-wide state.
118
+ */
119
+ export declare const defaultRuntime: Runtime;
120
+ export {};
@@ -0,0 +1,113 @@
1
+ import { preselect, runAgentLoop } from "./agent-loop.js";
2
+ import { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
3
+ import { capabilitiesFor, expireCapabilities, resetCapabilities } from "./capabilities.js";
4
+ import { configureClients, contextLimitFor, getClient, listModels, resetClients, servedWindow, } from "./client.js";
5
+ import { compactTranscript, runCompaction, summariser } from "./compaction.js";
6
+ import { continueTurn } from "./continuation.js";
7
+ import { configureEvents, emit, endRun, history, resetEvents, watch, } from "./events.js";
8
+ import { assembleContext, configureHooks, consult, gather, notify, resetHooks, withContext, } from "./hooks.js";
9
+ import { resetAll } from "./reset.js";
10
+ import { runTurn } from "./run-turn.js";
11
+ import { inScope, rootScope } from "./scope.js";
12
+ import { ask, askJson, resetHints, tryAsk } from "./side-task.js";
13
+ import { exportCapabilities, importCapabilities } from "./snapshot.js";
14
+ /**
15
+ * Every exported function that reads or writes what a runtime remembers, itself or through what
16
+ * it calls. The rest of the package is pure, and is the same function whichever runtime it is
17
+ * called beside.
18
+ */
19
+ const STATEFUL = {
20
+ runAgentLoop,
21
+ preselect,
22
+ runTurn,
23
+ continueTurn,
24
+ ask,
25
+ askJson,
26
+ tryAsk,
27
+ summariser,
28
+ runCompaction,
29
+ compactTranscript,
30
+ getClient,
31
+ listModels,
32
+ contextLimitFor,
33
+ servedWindow,
34
+ configureClients,
35
+ capabilitiesFor,
36
+ expireCapabilities,
37
+ exportCapabilities,
38
+ importCapabilities,
39
+ calibrate,
40
+ charsPerTokenFor,
41
+ emit,
42
+ endRun,
43
+ history,
44
+ watch,
45
+ configureEvents,
46
+ configureHooks,
47
+ gather,
48
+ notify,
49
+ consult,
50
+ assembleContext,
51
+ withContext,
52
+ resetClients,
53
+ resetCapabilities,
54
+ resetCalibration,
55
+ resetHints,
56
+ resetEvents,
57
+ resetHooks,
58
+ resetAll,
59
+ };
60
+ /** Makes the methods of the runtime whose state is `scope`. */
61
+ function bind(scope) {
62
+ const within = (fn) => (...args) => inScope(scope, () => fn(...args));
63
+ const methods = Object.fromEntries(Object.entries(STATEFUL).map(([name, fn]) => [
64
+ name,
65
+ within(fn),
66
+ ]));
67
+ return {
68
+ ...methods,
69
+ // What it hands back is called later, by `compactTranscript` or by the host, and asks then.
70
+ summariser: (...args) => within(summariser(...args)),
71
+ run: (fn) => inScope(scope, fn),
72
+ };
73
+ }
74
+ /**
75
+ * A runtime of its own: separate clients, listings, capability latches, hints, event bus and hook
76
+ * settings from the process's and from every other runtime's.
77
+ *
78
+ * For a host that must not share them — one that mints a key per tenant, where a shared pool has
79
+ * tenants evicting each other's clients and a shared bus has one tenant's run id readable by
80
+ * another — and for tests, where a runtime per case needs no `resetAll` between them and can run
81
+ * in parallel. A host with one deployment's worth of endpoints does not need one: the top-level
82
+ * functions are the default runtime's methods.
83
+ *
84
+ * The runtime is carried by async context rather than by argument, so `runAgentLoop` called as a
85
+ * method uses this runtime all the way down, and so does a top-level function the host calls from
86
+ * inside it — from `dispatch`, a hook, `onEvent`. Outside a method or `run`, a top-level function
87
+ * is the default runtime's again; a request handler that emits to a run a runtime started calls
88
+ * `runtime.emit`.
89
+ *
90
+ * Nothing needs closing. A runtime is garbage once nothing holds it; its one timer, the event
91
+ * bus's sweep, is unreferenced and stops rescheduling when its last run has been swept.
92
+ *
93
+ * @param options What to configure it with before first use. A part left out keeps the
94
+ * defaults — not the default runtime's settings, which a new runtime does not inherit.
95
+ */
96
+ export function createRuntime(options = {}) {
97
+ const runtime = bind(new Map());
98
+ if (options.clients)
99
+ runtime.configureClients(options.clients);
100
+ if (options.events)
101
+ runtime.configureEvents(options.events);
102
+ if (options.hooks)
103
+ runtime.configureHooks(options.hooks);
104
+ return runtime;
105
+ }
106
+ /**
107
+ * The runtime the top-level functions use when no other is running.
108
+ *
109
+ * `defaultRuntime.getClient(endpoint)` and `getClient(endpoint)` are the same call outside any
110
+ * runtime. Inside one they differ: the top-level function follows the runtime that is running,
111
+ * and this always reaches the process-wide state.
112
+ */
113
+ export const defaultRuntime = bind(rootScope);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Everything one runtime remembers: each module's state, under a key only that module holds.
3
+ *
4
+ * A map rather than a record of named caches, so a module that grows a cache declares it where
5
+ * it is used and `runtime.ts` does not have to hear about it.
6
+ */
7
+ export type Scope = Map<symbol, unknown>;
8
+ /** The default runtime's scope: what the top-level functions use when no runtime is running. */
9
+ export declare const rootScope: Scope;
10
+ /** The scope of the runtime whose method is running, or the default one outside any. */
11
+ export declare const currentScope: () => Scope;
12
+ /**
13
+ * Runs `fn` with `scope` as the current one, for it and for everything it awaits or schedules.
14
+ *
15
+ * @param scope The runtime's state. Nested calls replace it for their own duration only.
16
+ * @param fn What to run. Its return value, or what it throws, is handed straight back.
17
+ */
18
+ export declare const inScope: <T>(scope: Scope, fn: () => T) => T;
19
+ /** One module's state, read from whichever scope is current. See `scoped`. */
20
+ export interface Scoped<T> {
21
+ /** The current scope's copy, made on first use. */
22
+ (): T;
23
+ /** Drops the current scope's copy, so the next read makes a fresh one. */
24
+ reset(): void;
25
+ }
26
+ /**
27
+ * Declares a piece of module state that every runtime has its own copy of.
28
+ *
29
+ * What used to be `const cache = new Map()` at the top of a module is `scoped(() => new Map())`,
30
+ * and each use reads `cache()`. The copy is made lazily, so a runtime pays only for the modules
31
+ * it touches.
32
+ *
33
+ * @param create Builds a fresh, empty copy. Called once per scope, and again after `reset`.
34
+ */
35
+ export declare function scoped<T>(create: () => T): Scoped<T>;
package/dist/scope.js ADDED
@@ -0,0 +1,42 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * Which scope the code running now belongs to, carried across every `await` beneath a runtime's
4
+ * method. Async context rather than a parameter, because the state is read five calls down —
5
+ * `runAgentLoop` to `runTurn` to `negotiate` to `capabilitiesFor` — and threading it would put a
6
+ * runtime argument on every exported function in between.
7
+ */
8
+ const active = new AsyncLocalStorage();
9
+ /** The default runtime's scope: what the top-level functions use when no runtime is running. */
10
+ export const rootScope = new Map();
11
+ /** The scope of the runtime whose method is running, or the default one outside any. */
12
+ export const currentScope = () => active.getStore() ?? rootScope;
13
+ /**
14
+ * Runs `fn` with `scope` as the current one, for it and for everything it awaits or schedules.
15
+ *
16
+ * @param scope The runtime's state. Nested calls replace it for their own duration only.
17
+ * @param fn What to run. Its return value, or what it throws, is handed straight back.
18
+ */
19
+ export const inScope = (scope, fn) => active.run(scope, fn);
20
+ /**
21
+ * Declares a piece of module state that every runtime has its own copy of.
22
+ *
23
+ * What used to be `const cache = new Map()` at the top of a module is `scoped(() => new Map())`,
24
+ * and each use reads `cache()`. The copy is made lazily, so a runtime pays only for the modules
25
+ * it touches.
26
+ *
27
+ * @param create Builds a fresh, empty copy. Called once per scope, and again after `reset`.
28
+ */
29
+ export function scoped(create) {
30
+ const key = Symbol();
31
+ const read = () => {
32
+ const scope = currentScope();
33
+ if (!scope.has(key))
34
+ scope.set(key, create());
35
+ return scope.get(key);
36
+ };
37
+ return Object.assign(read, {
38
+ reset: () => {
39
+ currentScope().delete(key);
40
+ },
41
+ });
42
+ }
package/dist/side-task.js CHANGED
@@ -4,6 +4,7 @@ import { endpointId, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
5
  import { isTransient } from "./retry.js";
6
6
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
7
+ import { scoped } from "./scope.js";
7
8
  import { stripThinking } from "./thinking.js";
8
9
  /**
9
10
  * One-shot calls that support a run without being one: picking tools, naming a session,
@@ -39,13 +40,13 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
39
40
  * Only the `chat_template_kwargs` half is here. `reasoning_effort` is `negotiate`'s to latch, on
40
41
  * the same (endpoint, model) pair, where a run on that model can read it too.
41
42
  */
42
- const noHints = new Set();
43
+ const noHints = scoped(() => new Set());
43
44
  /** An (endpoint, model) pair as `noHints` holds it: `[endpointId, model]`, stringified. */
44
45
  export const hintKey = (endpoint, model) => JSON.stringify([endpoint, model]);
45
46
  /** The pairs that refused the hints, the live set, for `exportCapabilities` and `importCapabilities`. */
46
- export const refusedHints = () => noHints;
47
+ export const refusedHints = () => noHints();
47
48
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
48
- export const resetHints = () => noHints.clear();
49
+ export const resetHints = () => noHints().clear();
49
50
  /**
50
51
  * Whether a failure is the server complaining about the request, rather than failing to answer.
51
52
  *
@@ -130,7 +131,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
130
131
  const key = hintKey(endpointId(config), model);
131
132
  // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
132
133
  // false` next to `reasoning_effort: "high"` is a request that contradicts itself.
133
- const hints = !level && !noHints.has(key);
134
+ const hints = !level && !noHints().has(key);
134
135
  let response;
135
136
  try {
136
137
  response = await attempt(hints, true);
@@ -153,7 +154,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
153
154
  // cannot latch is blamed; if it was the effort after all, the next call is left with only
154
155
  // the effort to drop and latches that instead.
155
156
  if (hints)
156
- noHints.add(key);
157
+ noHints().add(key);
157
158
  else
158
159
  modelCapabilitiesFor(supports, model).reasoningEffort = false;
159
160
  }
package/llms.txt CHANGED
@@ -222,6 +222,13 @@ One turn, given as many attempts as the caller allows.
222
222
  - `RunTurnOptions` (type) — A retry is not the same event as a downgrade, but a watcher wants to be told about both.
223
223
  - `runTurn` — `request` is a callback rather than a body because the body has to be rebuilt from whatever the last attempt latched off: the tools it sends depend on `strictSchemas`, and `relaxTools` has to apply to the schemas that were just sanitised.
224
224
 
225
+ ### runtime
226
+
227
+ - `createRuntime` — A runtime of its own: separate clients, listings, capability latches, hints, event bus and hook settings from the process's and from every other runtime's.
228
+ - `defaultRuntime` — The runtime the top-level functions use when no other is running.
229
+ - `Runtime` (type) — One set of the package's caches, with the functions that use them as methods.
230
+ - `RuntimeOptions` (type) — What a runtime starts out configured with.
231
+
225
232
  ### schema-compat
226
233
 
227
234
  JSON Schema compatibility for llama.cpp-backed servers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.22.0",
3
+ "version": "2.23.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",