@cubicecho/agent-core 2.21.1 → 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/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
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, } from "./agent-loop.ts";
12
+ export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, buildBody, preselect, preview, resolveApiKey, runAgentLoop, type StepWindow, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult, taskCall, } from "./agent-loop.ts";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.ts";
14
14
  export { type Capabilities, capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, type ModelCapabilities, modelCapabilitiesFor, type NegotiateOptions, negotiate, resetCapabilities, } from "./capabilities.ts";
15
15
  export type { CatalogServer } from "./catalog.ts";
@@ -24,8 +24,9 @@ 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
- export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
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";
30
31
  export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
31
32
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * prompts, and whatever the run is about — because that is the caller's, and it is the part
10
10
  * that differs between one server and the next.
11
11
  */
12
- export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, } from "./agent-loop.js";
12
+ export { buildBody, preselect, preview, resolveApiKey, runAgentLoop, taskCall, } from "./agent-loop.js";
13
13
  export { calibrate, charsPerTokenFor, resetCalibration } from "./calibration.js";
14
14
  export { capabilitiesFor, EFFORT_LADDER, effortFor, expireCapabilities, modelCapabilitiesFor, negotiate, resetCapabilities, } from "./capabilities.js";
15
15
  export { configureClients, contextLimitFor, endpointId, endpointKey, FIRST_TOKEN_FACTOR, firstTokenMs, getClient, listModels, NO_KEY, resetClients, servedWindow, timeoutMs, } from "./client.js";
@@ -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();