@cubicecho/agent-core 2.22.0 → 2.24.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.
Files changed (49) hide show
  1. package/README.md +61 -6
  2. package/dist/agent-loop.d.ts +3 -2
  3. package/dist/agent-loop.js +385 -303
  4. package/dist/calibration.js +8 -6
  5. package/dist/capabilities.d.ts +36 -0
  6. package/dist/capabilities.js +116 -65
  7. package/dist/client.d.ts +5 -4
  8. package/dist/client.js +62 -57
  9. package/dist/compaction.js +5 -9
  10. package/dist/continuation.d.ts +4 -4
  11. package/dist/continuation.js +1 -4
  12. package/dist/events.d.ts +16 -1
  13. package/dist/events.js +64 -44
  14. package/dist/guards.d.ts +22 -0
  15. package/dist/guards.js +22 -0
  16. package/dist/hook-events.d.ts +22 -0
  17. package/dist/hook-events.js +22 -0
  18. package/dist/hooks.d.ts +15 -16
  19. package/dist/hooks.js +44 -44
  20. package/dist/index.d.ts +6 -4
  21. package/dist/index.js +6 -4
  22. package/dist/reset.d.ts +6 -2
  23. package/dist/reset.js +6 -2
  24. package/dist/retry.d.ts +13 -7
  25. package/dist/retry.js +14 -7
  26. package/dist/run-turn.d.ts +8 -1
  27. package/dist/runtime.d.ts +120 -0
  28. package/dist/runtime.js +113 -0
  29. package/dist/schema-compat.d.ts +23 -0
  30. package/dist/schema-compat.js +76 -59
  31. package/dist/scope.d.ts +35 -0
  32. package/dist/scope.js +42 -0
  33. package/dist/side-task.d.ts +5 -0
  34. package/dist/side-task.js +24 -52
  35. package/dist/snapshot.d.ts +2 -2
  36. package/dist/snapshot.js +27 -48
  37. package/dist/spec.d.ts +11 -12
  38. package/dist/spec.js +36 -66
  39. package/dist/stream.d.ts +7 -0
  40. package/dist/stream.js +8 -1
  41. package/dist/thinking.js +6 -4
  42. package/dist/tokens.d.ts +9 -2
  43. package/dist/tokens.js +10 -3
  44. package/dist/tool-calls.d.ts +9 -0
  45. package/dist/tool-calls.js +11 -6
  46. package/dist/tool-loading.d.ts +10 -0
  47. package/dist/tool-loading.js +15 -7
  48. package/llms.txt +19 -5
  49. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import OpenAI from "openai";
3
+ import { isPositive } from "./guards.js";
4
+ import { scoped } from "./scope.js";
3
5
  /**
4
6
  * The SDK insists on a non-empty key even where the server will not look at it. This is what it
5
7
  * gets. It also reads as "this endpoint has no key" at a call site, which an empty string does
6
8
  * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
7
9
  */
8
10
  export const NO_KEY = "agent-core";
11
+ /** A wait in the SDK's spelling: milliseconds, and `undefined` where zero or less means no limit. */
12
+ const limitMs = (seconds) => (seconds > 0 ? seconds * 1000 : undefined);
9
13
  /**
10
14
  * Zero, less, or absent means no limit, which the SDK spells as `undefined`.
11
15
  *
12
16
  * @param config Read for `requestTimeoutSeconds` alone.
13
17
  */
14
- export const timeoutMs = (config) => {
15
- const seconds = config.requestTimeoutSeconds ?? 0;
16
- return seconds > 0 ? seconds * 1000 : undefined;
17
- };
18
+ export const timeoutMs = (config) => limitMs(config.requestTimeoutSeconds ?? 0);
18
19
  /**
19
20
  * A client per endpoint, made once and kept.
20
21
  *
@@ -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
@@ -90,12 +92,11 @@ const evict = () => {
90
92
  */
91
93
  export function configureClients(options = {}) {
92
94
  for (const [name, value] of Object.entries(options)) {
93
- if (typeof value === "number" && value > 0) {
94
- poolLimits[name] = value;
95
- }
95
+ if (isPositive(value))
96
+ poolLimits()[name] = value;
96
97
  }
97
98
  evict();
98
- return { ...poolLimits };
99
+ return { ...poolLimits() };
99
100
  }
100
101
  /** How many idle windows the first chunk gets when `firstTokenSeconds` is not given. */
101
102
  export const FIRST_TOKEN_FACTOR = 5;
@@ -109,7 +110,7 @@ export const firstTokenMs = (config) => {
109
110
  const idle = timeoutMs(config);
110
111
  return idle === undefined ? undefined : idle * FIRST_TOKEN_FACTOR;
111
112
  }
112
- return config.firstTokenSeconds > 0 ? config.firstTokenSeconds * 1000 : undefined;
113
+ return limitMs(config.firstTokenSeconds);
113
114
  };
114
115
  /**
115
116
  * The client for an endpoint, built once and kept.
@@ -132,16 +133,16 @@ export function getClient(config) {
132
133
  // Stringified rather than joined on a separator: no character is impossible in a URL or a
133
134
  // key, and two different endpoints must never resolve to the same cached client.
134
135
  const key = JSON.stringify([config.baseUrl, apiKey, timeout]);
135
- const existing = clients.get(key);
136
+ const existing = clients().get(key);
136
137
  if (existing) {
137
138
  // Re-inserted so it counts as the youngest. A `Map` keeps insertion order and hands the
138
139
  // oldest key over first, which is the whole of the LRU below.
139
- clients.delete(key);
140
- clients.set(key, existing);
140
+ clients().delete(key);
141
+ clients().set(key, existing);
141
142
  return existing;
142
143
  }
143
144
  const client = new OpenAI({ baseURL: config.baseUrl, apiKey, timeout, maxRetries: 0 });
144
- clients.set(key, client);
145
+ clients().set(key, client);
145
146
  evict();
146
147
  return client;
147
148
  }
@@ -161,7 +162,7 @@ const CONTEXT_KEYS = [
161
162
  "context_window",
162
163
  "n_ctx",
163
164
  ];
164
- const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
165
+ const positive = (value) => (isPositive(value) ? value : 0);
165
166
  /**
166
167
  * A listing entry's window, and whether it is only the one the model was trained with.
167
168
  *
@@ -197,14 +198,14 @@ function contextLengthOf(model) {
197
198
  * It is only ever a cache of something asked for anyway, and a listing that fails leaves
198
199
  * whatever was there rather than emptying it.
199
200
  */
200
- const listings = new Map();
201
+ const listings = scoped(() => new Map());
201
202
  /**
202
203
  * The models in each endpoint's last listing whose window is only the trained one, by id.
203
204
  *
204
205
  * Kept beside `listings` rather than on `ModelInfo`, so what `listModels` hands back is the shape
205
206
  * it has always been. Replaced with each listing that lands, and left alone by one that fails.
206
207
  */
207
- const trainedOnly = new Map();
208
+ const trainedOnly = scoped(() => new Map());
208
209
  /**
209
210
  * When an endpoint was last asked about a model it did not name, keyed on the two together.
210
211
  *
@@ -219,7 +220,7 @@ const trainedOnly = new Map();
219
220
  * picked up within the minute instead of at the next restart. Bounded by the (endpoint, model)
220
221
  * pairs actually asked about, which is the bound the listings themselves have.
221
222
  */
222
- const misses = new Map();
223
+ const misses = scoped(() => new Map());
223
224
  /**
224
225
  * What counts as one endpoint, everywhere in this package that has to remember something about
225
226
  * one — the model listings, `capabilities`, and the no-thinking hints in `side-task`.
@@ -240,6 +241,8 @@ const misses = new Map();
240
241
  * it; see `listings`.
241
242
  */
242
243
  export const endpointKey = (config) => JSON.stringify([config.baseUrl, config.apiKey || NO_KEY]);
244
+ /** One model on one endpoint, as the caches below key it: stringified, so neither half runs into the other. */
245
+ const modelKey = (endpoint, model) => JSON.stringify([endpoint, model]);
243
246
  /**
244
247
  * `endpointKey` hashed, for the remembered facts that can leave the process.
245
248
  *
@@ -257,9 +260,9 @@ export const endpointId = (config) => createHash("sha256").update(endpointKey(co
257
260
  * A model's entry stays until `resetClients`, like a listing; a server that answered without one
258
261
  * is asked again after `listingMissMs`, like a listing that did not name the model.
259
262
  */
260
- const served = new Map();
263
+ const served = scoped(() => new Map());
261
264
  /** Endpoints with neither native route, which are not asked for either again. */
262
- const unserved = new Set();
265
+ const unserved = scoped(() => new Set());
263
266
  /**
264
267
  * Endpoints whose `/api/v0/models` answered with an error, and when.
265
268
  *
@@ -271,11 +274,11 @@ const unserved = new Set();
271
274
  * Per endpoint and on its own, whatever `/props` said: a server whose `/props` answers without a
272
275
  * window is never `unserved`, and without this its missing route was asked again with every miss.
273
276
  */
274
- const refusals = new Map();
277
+ const refusals = scoped(() => new Map());
275
278
  /** Whether `/api/v0/models` is to be left alone on this endpoint for now. */
276
279
  const refused = (endpoint) => {
277
- const refusal = refusals.get(endpoint);
278
- return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits.listingMissMs));
280
+ const refusal = refusals().get(endpoint);
281
+ return (refusal !== undefined && (refusal.gone || Date.now() - refusal.at < poolLimits().listingMissMs));
279
282
  };
280
283
  /** How long a probe may take before the window is taken from the listing instead. */
281
284
  const PROBE_TIMEOUT_MS = 10_000;
@@ -324,11 +327,11 @@ async function probe(config, path) {
324
327
  */
325
328
  export async function servedWindow(config) {
326
329
  const endpoint = endpointKey(config);
327
- if (unserved.has(endpoint))
330
+ if (unserved().has(endpoint))
328
331
  return 0;
329
- 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 key = modelKey(endpoint, config.model);
333
+ const known = served().get(key);
334
+ if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs))
332
335
  return known.window;
333
336
  let window = 0;
334
337
  try {
@@ -341,17 +344,17 @@ export async function servedWindow(config) {
341
344
  if (!refused(endpoint)) {
342
345
  const lmstudio = await probe(config, "/api/v0/models");
343
346
  if (lmstudio.failed)
344
- refusals.set(endpoint, { at: Date.now(), gone: lmstudio.missing });
347
+ refusals().set(endpoint, { at: Date.now(), gone: lmstudio.missing });
345
348
  else
346
- refusals.delete(endpoint);
349
+ refusals().delete(endpoint);
347
350
  const { data } = (lmstudio.body ?? {});
348
351
  const entry = Array.isArray(data)
349
352
  ? data.find((model) => model?.id === config.model)
350
353
  : undefined;
351
354
  window = positive(entry?.loaded_context_length);
352
355
  }
353
- if (props.missing && refusals.get(endpoint)?.gone) {
354
- unserved.add(endpoint);
356
+ if (props.missing && refusals().get(endpoint)?.gone) {
357
+ unserved().add(endpoint);
355
358
  return 0;
356
359
  }
357
360
  }
@@ -359,7 +362,7 @@ export async function servedWindow(config) {
359
362
  catch {
360
363
  return 0;
361
364
  }
362
- served.set(key, { window, at: Date.now() });
365
+ served().set(key, { window, at: Date.now() });
363
366
  return window;
364
367
  }
365
368
  /**
@@ -379,8 +382,8 @@ export async function listModels(config) {
379
382
  })
380
383
  .sort((a, b) => a.id.localeCompare(b.id));
381
384
  const key = endpointKey(config);
382
- listings.set(key, models);
383
- trainedOnly.set(key, trained);
385
+ listings().set(key, models);
386
+ trainedOnly().set(key, trained);
384
387
  return models;
385
388
  }
386
389
  /**
@@ -411,7 +414,9 @@ export async function contextLimitFor(config, declared = 0) {
411
414
  if (declared > 0)
412
415
  return declared;
413
416
  const key = endpointKey(config);
414
- const listed = () => listings.get(key)?.find((model) => model.id === config.model);
417
+ const listed = () => listings()
418
+ .get(key)
419
+ ?.find((model) => model.id === config.model);
415
420
  // The listing is asked for again when it does not name this model, rather than only when
416
421
  // there is no listing at all. Models arrive after a process starts — an `ollama pull` on a
417
422
  // box that has been up a week, a worker added to a router, a name the operator has only just
@@ -420,12 +425,12 @@ export async function contextLimitFor(config, declared = 0) {
420
425
  // context meter and, in a consumer that compacts on it, compaction: the session then runs at
421
426
  // the window instead of under it and fails against the endpoint's own refusal.
422
427
  if (!listed()) {
423
- const missKey = JSON.stringify([key, config.model]);
424
- const asked = misses.get(missKey);
428
+ const missKey = modelKey(key, config.model);
429
+ const asked = misses().get(missKey);
425
430
  // Asked again, but not on every call. A model that is never coming answers the same zero
426
431
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
427
432
  // trip for each of them; `listingMissMs` is how long that answer is allowed to stand.
428
- if (asked === undefined || Date.now() - asked >= poolLimits.listingMissMs) {
433
+ if (asked === undefined || Date.now() - asked >= poolLimits().listingMissMs) {
429
434
  // A failure is not remembered: an endpoint that was down when the last run started is not
430
435
  // an endpoint with no models, and a window nobody could ask about is not a failed run.
431
436
  const answered = await listModels(config).then(() => true, () => false);
@@ -435,9 +440,9 @@ export async function contextLimitFor(config, declared = 0) {
435
440
  // half a minute.
436
441
  if (answered) {
437
442
  if (listed())
438
- misses.delete(missKey);
443
+ misses().delete(missKey);
439
444
  else
440
- misses.set(missKey, Date.now());
445
+ misses().set(missKey, Date.now());
441
446
  }
442
447
  }
443
448
  }
@@ -445,7 +450,7 @@ export async function contextLimitFor(config, declared = 0) {
445
450
  const fromListing = entry?.contextLength ?? 0;
446
451
  // A top-level key settles it. A trained window does not: llama.cpp lists the one the model was
447
452
  // 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))
453
+ if (fromListing > 0 && !trainedOnly().get(key)?.has(config.model))
449
454
  return fromListing;
450
455
  const window = await servedWindow(config);
451
456
  return window > 0 ? window : fromListing;
@@ -455,12 +460,12 @@ export async function contextLimitFor(config, declared = 0) {
455
460
  * tests, and for a settings change under test.
456
461
  */
457
462
  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();
463
+ poolLimits.reset();
464
+ clients().clear();
465
+ listings().clear();
466
+ misses().clear();
467
+ trainedOnly().clear();
468
+ served().clear();
469
+ unserved().clear();
470
+ refusals().clear();
466
471
  }
@@ -1,4 +1,4 @@
1
- import { consult, notify, turnMessages, } from "./hooks.js";
1
+ import { consult, notify, textOf, turnMessages, } from "./hooks.js";
2
2
  import { messageTokens } from "./retry.js";
3
3
  import { ask } from "./side-task.js";
4
4
  import { holdsDefinitions } from "./tool-loading.js";
@@ -24,12 +24,6 @@ export const SUMMARY_PROMPT = "You maintain the running memory of a long convers
24
24
  * How a summary message opens, which is also how `planCompaction` knows one from a system prompt.
25
25
  */
26
26
  export const SUMMARY_LEAD = "Summary of the earlier part of this conversation, which is no longer shown in full:\n\n";
27
- /** A message's content as plain text: parts joined, anything but text left out. */
28
- const textOf = (content) => typeof content === "string"
29
- ? content
30
- : Array.isArray(content)
31
- ? content.map((part) => ("text" in part ? part.text : "")).join(" ")
32
- : "";
33
27
  /** What the summariser reads for one message: its text and the calls it made. */
34
28
  const messageText = (message) => {
35
29
  const calls = "tool_calls" in message && message.tool_calls
@@ -98,6 +92,8 @@ export function pruneToolResults(messages, { keepLast = 5, maxChars = 256 } = {}
98
92
  }
99
93
  return out ?? messages;
100
94
  }
95
+ /** An index held between the start of the transcript and `most`, for a caller's `from` that is neither. */
96
+ const clamp = (index, most) => Math.min(Math.max(index, 0), most);
101
97
  /**
102
98
  * Where to fold a transcript that has grown into its window, or `undefined` when it should not be.
103
99
  *
@@ -133,7 +129,7 @@ export function planCompaction(messages, { limit, used, compactAt = COMPACT_AT,
133
129
  if (cost < limit * compactAt)
134
130
  return undefined;
135
131
  const head = systemHead(messages);
136
- const from = Math.min(Math.max(givenFrom ?? head.from, 0), messages.length);
132
+ const from = clamp(givenFrom ?? head.from, messages.length);
137
133
  const previous = givenPrevious ?? head.previous;
138
134
  let cut = messages.length;
139
135
  let after;
@@ -256,7 +252,7 @@ export async function runCompaction(messages, plan, summarise, { hooks, forced =
256
252
  export function applyCompaction(messages, record, { from } = {}) {
257
253
  if (!record?.summary.trim())
258
254
  return messages;
259
- const head = Math.min(Math.max(from ?? systemHead(messages).from, 0), record.through);
255
+ const head = clamp(from ?? systemHead(messages).from, record.through);
260
256
  return [
261
257
  ...messages.slice(0, head).filter((message) => !isSummary(message)),
262
258
  summaryMessage(record.summary),
@@ -1,6 +1,6 @@
1
- import OpenAI from "openai";
2
- import { type Capabilities, type ModelCapabilities } from "./capabilities.ts";
3
- import { type RunTurnOptions } from "./run-turn.ts";
1
+ import type OpenAI from "openai";
2
+ import { type Capabilities } from "./capabilities.ts";
3
+ import { type RequestBuilder, type RunTurnOptions } from "./run-turn.ts";
4
4
  import type { Turn } from "./stream.ts";
5
5
  /**
6
6
  * Picking up an answer the token ceiling cut off, instead of keeping half of it.
@@ -56,4 +56,4 @@ export declare const isContinuable: (turn: Turn) => boolean;
56
56
  * @param options `runTurn`'s options, with `model` needed for the latch — without one nothing is
57
57
  * latched and each continuation finds out again — and the cap on continuations.
58
58
  */
59
- export declare function continueTurn(client: OpenAI, supports: Capabilities, request: (supports: Capabilities, model: ModelCapabilities | undefined) => OpenAI.ChatCompletionCreateParamsStreaming, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
59
+ export declare function continueTurn(client: OpenAI, supports: Capabilities, request: RequestBuilder, turn: Turn, { maxContinuations, ...options }?: ContinueTurnOptions): Promise<Turn>;
@@ -1,7 +1,6 @@
1
- import OpenAI from "openai";
2
1
  import { modelCapabilitiesFor } from "./capabilities.js";
3
2
  import { errorMessage } from "./errors.js";
4
- import { ContextOverflow } from "./retry.js";
3
+ import { ContextOverflow, refusesRequest } from "./retry.js";
5
4
  import { runTurn } from "./run-turn.js";
6
5
  /**
7
6
  * Whether a turn is one a continuation can finish: cut off at the ceiling, with an answer begun
@@ -79,8 +78,6 @@ function joinUsage(first, next) {
79
78
  }
80
79
  return joined;
81
80
  }
82
- /** Whether a failure is the endpoint refusing the request as written, rather than losing it. */
83
- const refusesRequest = (error) => error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
84
81
  /**
85
82
  * Carries on an answer the token ceiling cut off, by sending the transcript again with the answer
86
83
  * so far as a trailing assistant message, and joins the pieces into one turn.
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.
@@ -173,6 +175,19 @@ export interface RunEvent {
173
175
  * a duplicate `seq`, which is the one thing the sequence is for.
174
176
  */
175
177
  export type RunEventInput = Pick<RunEvent, "kind"> & Partial<Omit<RunEvent, "kind" | "runId" | "seq">>;
178
+ /**
179
+ * A whole event from what a caller said of it: the empty defaults under it, the run and sequence over it.
180
+ *
181
+ * The one place the defaults are written, so an event the bus stamps, one the loop keeps for its
182
+ * own metrics and the notice a slow watcher is sent cannot come to disagree about what an unset
183
+ * field reads as.
184
+ *
185
+ * @param input What happened. An `at` it carries wins over the one given here.
186
+ * @param runId The run it belongs to. Written after `input`, which gets no say in it.
187
+ * @param seq Its place in that run, the same.
188
+ * @param at When, for an input that does not say.
189
+ */
190
+ export declare const stamp: (input: RunEventInput, runId: string, seq: number, at: number) => RunEvent;
176
191
  /**
177
192
  * Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
178
193
  * loop threw where it could not be caught. The sweep gets there on its own; this is for a
package/dist/events.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { isPositive } from "./guards.js";
2
+ import { scoped } from "./scope.js";
1
3
  import { LOAD_TOOLS } from "./tool-loading.js";
2
4
  /** The numbers a run of the shape this bus was written for wants. */
3
5
  const DEFAULTS = {
@@ -6,14 +8,15 @@ const DEFAULTS = {
6
8
  retainMs: 60_000,
7
9
  retainUnendedMs: 30 * 60_000,
8
10
  };
9
- /** What is in force now. Read where it is used, so a change applies from the next event. */
10
- let limits = { ...DEFAULTS };
11
+ const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweeping: null }));
11
12
  /**
12
13
  * Changes what the bus keeps, for a process whose runs are not shaped like the ones these
13
14
  * defaults were chosen for.
14
15
  *
15
- * The bus is one module-level thing rather than an object a caller holds, so this is too: it is
16
+ * The bus is one thing per runtime rather than an object a caller holds, so this is too: it is
16
17
  * a deployment's setting, said once at startup, and not something to move around under a run.
18
+ * Called at the top level that is the process's bus; a runtime made by `createRuntime` has its
19
+ * own, set through its options or its own `configureEvents`.
17
20
  * What it costs is memory against how much of a run a late or slow watcher can still read —
18
21
  * a server with hundreds of concurrent runs wants a smaller backlog, and one whose tool calls
19
22
  * take an hour wants a longer `retainUnendedMs` than the thirty minutes assumed here.
@@ -27,14 +30,37 @@ let limits = { ...DEFAULTS };
27
30
  * @returns Everything in force afterwards, including what this call did not change.
28
31
  */
29
32
  export function configureEvents(options = {}) {
33
+ const { limits } = bus();
30
34
  for (const [name, value] of Object.entries(options)) {
31
- if (typeof value === "number" && value > 0)
35
+ if (isPositive(value))
32
36
  limits[name] = value;
33
37
  }
34
38
  return { ...limits };
35
39
  }
36
- const streams = new Map();
37
- const streamFor = (runId) => {
40
+ /**
41
+ * A whole event from what a caller said of it: the empty defaults under it, the run and sequence over it.
42
+ *
43
+ * The one place the defaults are written, so an event the bus stamps, one the loop keeps for its
44
+ * own metrics and the notice a slow watcher is sent cannot come to disagree about what an unset
45
+ * field reads as.
46
+ *
47
+ * @param input What happened. An `at` it carries wins over the one given here.
48
+ * @param runId The run it belongs to. Written after `input`, which gets no say in it.
49
+ * @param seq Its place in that run, the same.
50
+ * @param at When, for an input that does not say.
51
+ */
52
+ export const stamp = (input, runId, seq, at) => ({
53
+ at,
54
+ text: "",
55
+ name: "",
56
+ step: "",
57
+ ok: null,
58
+ usage: null,
59
+ ...input,
60
+ runId,
61
+ seq,
62
+ });
63
+ const streamFor = ({ streams }, runId) => {
38
64
  const existing = streams.get(runId);
39
65
  if (existing)
40
66
  return existing;
@@ -60,9 +86,9 @@ const streamFor = (runId) => {
60
86
  *
61
87
  * One timer for the whole map, rescheduled only while there is something in it to expire.
62
88
  */
63
- let sweeping = null;
64
- function sweep() {
65
- sweeping = null;
89
+ function sweep(held) {
90
+ const { limits, streams } = held;
91
+ held.sweeping = null;
66
92
  const now = Date.now();
67
93
  for (const [runId, stream] of streams) {
68
94
  if (stream.listeners.size)
@@ -70,13 +96,13 @@ function sweep() {
70
96
  if (stream.touched <= now - (stream.ended ? limits.retainMs : limits.retainUnendedMs))
71
97
  streams.delete(runId);
72
98
  }
73
- scheduleSweep();
99
+ scheduleSweep(held);
74
100
  }
75
- function scheduleSweep() {
76
- if (sweeping || streams.size === 0)
101
+ function scheduleSweep(held) {
102
+ if (held.sweeping || held.streams.size === 0)
77
103
  return;
78
- sweeping = setTimeout(sweep, limits.retainMs);
79
- sweeping.unref?.();
104
+ held.sweeping = setTimeout(() => sweep(held), held.limits.retainMs);
105
+ held.sweeping.unref?.();
80
106
  }
81
107
  /**
82
108
  * Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
@@ -86,6 +112,7 @@ function scheduleSweep() {
86
112
  * @param runId The run to forget. An id nothing was emitted under is ignored.
87
113
  */
88
114
  export function endRun(runId) {
115
+ const { streams } = bus();
89
116
  const stream = streams.get(runId);
90
117
  if (!stream)
91
118
  return;
@@ -106,21 +133,12 @@ export function endRun(runId) {
106
133
  * @param input The event. `kind` is required; `runId` and `seq` are not a caller's to set.
107
134
  */
108
135
  export function emit(runId, input) {
109
- const stream = streamFor(runId);
136
+ const held = bus();
137
+ const { limits } = held;
138
+ const stream = streamFor(held, runId);
110
139
  // One clock read, used for both the event and the sweep's bookkeeping.
111
140
  const at = Date.now();
112
- const event = {
113
- at,
114
- text: "",
115
- name: "",
116
- step: "",
117
- ok: null,
118
- usage: null,
119
- ...input,
120
- // After the spread, not before: these are the bus's and a caller does not get a say.
121
- runId,
122
- seq: ++stream.seq,
123
- };
141
+ const event = stamp(input, runId, ++stream.seq, at);
124
142
  stream.events.push(event);
125
143
  stream.touched = at;
126
144
  if (stream.events.length > limits.maxEvents + limits.trimSlack) {
@@ -130,7 +148,7 @@ export function emit(runId, input) {
130
148
  // then dropped: a finished run's record is the row, not this.
131
149
  if (event.kind === "done")
132
150
  stream.ended = true;
133
- scheduleSweep();
151
+ scheduleSweep(held);
134
152
  for (const listener of stream.listeners) {
135
153
  // The guarantee above is the point of this: a listener that throws must not take out the
136
154
  // emitter, the listeners after it, or the bookkeeping already done above.
@@ -155,8 +173,14 @@ export function emit(runId, input) {
155
173
  * function it is suspended at an `await` rather than at a `yield`, and a `return()` there is
156
174
  * queued behind a promise only the next event can settle. An abort resolves that promise itself.
157
175
  */
158
- export async function* watch(runId, signal) {
159
- const stream = streamFor(runId);
176
+ export function watch(runId, signal) {
177
+ // Read here, not in the generator: its body runs when the first event is asked for, and by
178
+ // then the caller may be outside the runtime whose run this is.
179
+ return watching(bus(), runId, signal);
180
+ }
181
+ async function* watching(held, runId, signal) {
182
+ const { limits, streams } = held;
183
+ const stream = streamFor(held, runId);
160
184
  // A cursor rather than `shift()`. Draining a backlog an event at a time off the front of an
161
185
  // array is a copy of the whole array per event, which on the ten-thousand-delta run this bus
162
186
  // is built for is the one quadratic left in the file. The prefix behind the cursor is dropped
@@ -217,17 +241,11 @@ export async function* watch(runId, signal) {
217
241
  // Inside the gap there is nothing to collide with: those seqs reach no watcher.
218
242
  const gap = dropped;
219
243
  dropped = 0;
220
- yield {
221
- runId,
222
- seq: event.seq - 1,
223
- at: event.at,
244
+ yield stamp({
224
245
  kind: "notice",
225
246
  text: `${gap} event(s) dropped: this watcher fell too far behind`,
226
- name: "",
227
247
  step: event.step,
228
- ok: null,
229
- usage: null,
230
- };
248
+ }, runId, event.seq - 1, event.at);
231
249
  }
232
250
  yield event;
233
251
  // `done` is the last event a run will ever have, so the subscription completes rather
@@ -272,7 +290,7 @@ export async function* watch(runId, signal) {
272
290
  *
273
291
  * @param runId The run to read. An unknown or already-swept run gives an empty array.
274
292
  */
275
- export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
293
+ export const history = (runId) => [...(bus().streams.get(runId)?.events ?? [])];
276
294
  /**
277
295
  * Test seam: forget every run, so one test's events cannot be read by the next.
278
296
  *
@@ -286,11 +304,13 @@ export const history = (runId) => [...(streams.get(runId)?.events ?? [])];
286
304
  * teardown configures again, which is the same line it already wrote once.
287
305
  */
288
306
  export const resetEvents = () => {
289
- streams.clear();
290
- if (sweeping)
291
- clearTimeout(sweeping);
292
- sweeping = null;
293
- limits = { ...DEFAULTS };
307
+ const held = bus();
308
+ held.streams.clear();
309
+ if (held.sweeping)
310
+ clearTimeout(held.sweeping);
311
+ held.sweeping = null;
312
+ // In place, so a watcher still open reads the defaults too and not the caps it began under.
313
+ Object.assign(held.limits, DEFAULTS);
294
314
  };
295
315
  /**
296
316
  * Consecutive tokens of one kind are one thing being said, not hundreds of things.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The checks a value read off the wire is put through before anything is read from it.
3
+ *
4
+ * A leaf with no imports, because the modules that need these — a browser-safe `spec`, the schema
5
+ * walk, the tool-call reader, the snapshot importer — otherwise share nothing, and each had grown
6
+ * its own copy.
7
+ */
8
+ /**
9
+ * Whether a value is a plain keyed object: not null, and not an array.
10
+ *
11
+ * @param value Anything, usually just parsed. An array is an object to `typeof` and not to this.
12
+ */
13
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
14
+ /**
15
+ * Whether a value is a number above zero, which is what a limit has to be to be one.
16
+ *
17
+ * `Infinity` passes and `NaN` does not, so a caller lifting a bound and one handing over a
18
+ * half-built config are told apart without either being asked.
19
+ *
20
+ * @param value Anything, usually a setting that may be absent, zero or mistyped.
21
+ */
22
+ export declare const isPositive: (value: unknown) => value is number;