@cubicecho/agent-core 2.23.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.
@@ -1,5 +1,6 @@
1
- import { CHARS_PER_TOKEN, requestChars, toolsChars } from "./retry.js";
1
+ import { requestChars, toolsChars } from "./retry.js";
2
2
  import { scoped } from "./scope.js";
3
+ import { CHARS_PER_TOKEN } from "./tokens.js";
3
4
  /**
4
5
  * How many characters a token is worth on one model, learned from what its endpoint reports.
5
6
  *
@@ -118,6 +118,20 @@ export interface ModelCapabilities {
118
118
  */
119
119
  assistantPrefill: boolean;
120
120
  }
121
+ /** The name of one of a model's on/off flags: every `boolean` on `ModelCapabilities`. */
122
+ export type ModelFlag = {
123
+ [Key in keyof ModelCapabilities]-?: ModelCapabilities[Key] extends boolean ? Key : never;
124
+ }[keyof ModelCapabilities];
125
+ /**
126
+ * A model's on/off flags as it starts out, every one optimistic.
127
+ *
128
+ * A record over every boolean on `ModelCapabilities`, so a flag added there does not compile until
129
+ * it is listed here — and this is the list a snapshot is exported, imported and judged empty by,
130
+ * where each of those used to name the flags again and one could be missed.
131
+ */
132
+ export declare const OPTIMISTIC_MODEL: Readonly<Record<ModelFlag, true>>;
133
+ /** The names of those flags, for a caller that reads or latches each in turn. */
134
+ export declare const MODEL_FLAGS: ModelFlag[];
121
135
  /**
122
136
  * What this endpoint is known not to support. The same object every time, so what `negotiate`
123
137
  * latches off stays off.
@@ -210,6 +224,28 @@ export declare const EFFORT_LADDER: readonly ["none", "minimal", "low", "medium"
210
224
  * @param asked What the config asks for. `"off"` and absent mean no effort.
211
225
  */
212
226
  export declare function effortFor(refused: ModelCapabilities | undefined, asked: string | undefined): string;
227
+ /**
228
+ * The reply ceiling and the temperature as this model takes them, to spread into a request body.
229
+ *
230
+ * The reasoning models want the ceiling spelled the other way, and one that will only run at the
231
+ * temperature it was built with is sent none. Both are tested `=== false`, so a caller that named
232
+ * no model sends what one that has refused nothing is sent; see `ModelCapabilities.legacyTokenLimit`.
233
+ *
234
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
235
+ * has refused nothing.
236
+ * @param maxTokens The ceiling. Zero or less sends none and leaves it to the server: a zero sent
237
+ * as `max_tokens: 0` asks for an empty reply.
238
+ * @param temperature What to sample at, where the model takes one that was picked for it.
239
+ */
240
+ export declare const ceilingAndTemperature: (refused: ModelCapabilities | undefined, maxTokens: number, temperature: number) => {
241
+ max_completion_tokens: number;
242
+ temperature?: number | undefined;
243
+ } | {
244
+ max_tokens: number;
245
+ temperature?: number | undefined;
246
+ } | {
247
+ temperature?: number | undefined;
248
+ };
213
249
  /** What `negotiate` takes besides the request. All optional. */
214
250
  export interface NegotiateOptions {
215
251
  /**
@@ -2,6 +2,22 @@ import { endpointId } from "./client.js";
2
2
  import { errorMessage } from "./errors.js";
3
3
  import { isGrammarError } from "./schema-compat.js";
4
4
  import { scoped } from "./scope.js";
5
+ /**
6
+ * A model's on/off flags as it starts out, every one optimistic.
7
+ *
8
+ * A record over every boolean on `ModelCapabilities`, so a flag added there does not compile until
9
+ * it is listed here — and this is the list a snapshot is exported, imported and judged empty by,
10
+ * where each of those used to name the flags again and one could be missed.
11
+ */
12
+ export const OPTIMISTIC_MODEL = {
13
+ reasoningEffort: true,
14
+ legacyTokenLimit: true,
15
+ chosenTemperature: true,
16
+ structuredOutput: true,
17
+ assistantPrefill: true,
18
+ };
19
+ /** The names of those flags, for a caller that reads or latches each in turn. */
20
+ export const MODEL_FLAGS = Object.keys(OPTIMISTIC_MODEL);
5
21
  /**
6
22
  * What each endpoint cannot do, remembered for the life of the process.
7
23
  *
@@ -51,15 +67,7 @@ export function capabilitiesFor(baseUrl, apiKey) {
51
67
  export function modelCapabilitiesFor(supports, model) {
52
68
  let known = supports.models.get(model);
53
69
  if (!known) {
54
- known = {
55
- reasoningEffort: true,
56
- legacyTokenLimit: true,
57
- chosenTemperature: true,
58
- refusedFields: new Set(),
59
- structuredOutput: true,
60
- refusedEfforts: new Set(),
61
- assistantPrefill: true,
62
- };
70
+ known = { ...OPTIMISTIC_MODEL, refusedFields: new Set(), refusedEfforts: new Set() };
63
71
  supports.models.set(model, known);
64
72
  }
65
73
  return known;
@@ -261,6 +269,27 @@ export function effortFor(refused, asked) {
261
269
  return asked;
262
270
  return nextEffort(refused, asked) ?? asked;
263
271
  }
272
+ /**
273
+ * The reply ceiling and the temperature as this model takes them, to spread into a request body.
274
+ *
275
+ * The reasoning models want the ceiling spelled the other way, and one that will only run at the
276
+ * temperature it was built with is sent none. Both are tested `=== false`, so a caller that named
277
+ * no model sends what one that has refused nothing is sent; see `ModelCapabilities.legacyTokenLimit`.
278
+ *
279
+ * @param refused What the model has refused, as `negotiate` hands it over. Absent is a model that
280
+ * has refused nothing.
281
+ * @param maxTokens The ceiling. Zero or less sends none and leaves it to the server: a zero sent
282
+ * as `max_tokens: 0` asks for an empty reply.
283
+ * @param temperature What to sample at, where the model takes one that was picked for it.
284
+ */
285
+ export const ceilingAndTemperature = (refused, maxTokens, temperature) => ({
286
+ ...(maxTokens > 0
287
+ ? refused?.legacyTokenLimit === false
288
+ ? { max_completion_tokens: maxTokens }
289
+ : { max_tokens: maxTokens }
290
+ : {}),
291
+ ...(refused?.chosenTemperature === false ? {} : { temperature }),
292
+ });
264
293
  /**
265
294
  * How to answer a refused effort *value*, or `undefined` when there is nothing new to learn or
266
295
  * nowhere left to go — in which case the refusal is the caller's, as it was before this existed.
@@ -347,9 +376,58 @@ function unknownFields(detail) {
347
376
  .split(/[.[]/)[0])
348
377
  .filter(Boolean);
349
378
  }
350
- /** Whether a refusal names a droppable field this model has not already refused. */
351
- const refusesDroppable = (detail, droppable, refused) => droppable.size > 0 &&
352
- unknownFields(detail).some((field) => droppable.has(field) && !refused.refusedFields.has(field));
379
+ /** The answer to a refusal that is the endpoint's: a flag still set, and a wording that clears it. */
380
+ const endpointAnswer = (flag, matches, notice) => ({ detail, supports }) => {
381
+ if (!supports[flag] || !matches(detail))
382
+ return undefined;
383
+ supports[flag] = false;
384
+ return notice;
385
+ };
386
+ /** The same for a refusal that is the model's, which only a request that named one can meet. */
387
+ const modelAnswer = (flag, matches, notice) => ({ detail, named }) => {
388
+ if (!named?.refused[flag] || !matches(detail))
389
+ return undefined;
390
+ named.refused[flag] = false;
391
+ return notice(named.name);
392
+ };
393
+ /** An effort refused by value: latched, so `effortFor` sends the next rung down. */
394
+ const effortStepAnswer = ({ detail, named }) => {
395
+ const stepped = named && planEffortStep(detail, named.refused);
396
+ if (!named || !stepped)
397
+ return undefined;
398
+ named.refused.refusedEfforts.add(stepped.value);
399
+ if (stepped.supported)
400
+ named.refused.supportedEfforts = stepped.supported;
401
+ return `${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`;
402
+ };
403
+ /** Fields the endpoint has never heard of, where they are droppable and not refused already. */
404
+ const droppedFieldsAnswer = ({ detail, named, droppable }) => {
405
+ if (!named || droppable.size === 0)
406
+ return undefined;
407
+ const fields = unknownFields(detail).filter((field) => droppable.has(field) && !named.refused.refusedFields.has(field));
408
+ if (!fields.length)
409
+ return undefined;
410
+ for (const field of fields)
411
+ named.refused.refusedFields.add(field);
412
+ return `${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`;
413
+ };
414
+ /**
415
+ * Every refusal `negotiate` answers, in the order it asks. The first to answer is the only one.
416
+ *
417
+ * The order is behaviour rather than layout: one error text can satisfy two of these — a message
418
+ * about `reasoning_effort` is a refused field to one and a refused value to the next — and the
419
+ * earlier row is the reading that wins.
420
+ */
421
+ const ANSWERS = [
422
+ endpointAnswer("strictSchemas", isGrammarError, "server could not build a grammar; retrying without pattern/format"),
423
+ endpointAnswer("usageInStream", (detail) => REJECTS_USAGE.test(detail), "server rejected stream_options; token counts unavailable"),
424
+ modelAnswer("reasoningEffort", rejectsEffort, (name) => `${name} does not take a reasoning effort; retrying without one`),
425
+ effortStepAnswer,
426
+ modelAnswer("legacyTokenLimit", wantsCompletionLimit, (name) => `${name} wants max_completion_tokens; retrying with the limit spelled that way`),
427
+ modelAnswer("chosenTemperature", refusesChosenTemperature, (name) => `${name} takes only its own temperature; retrying without ours`),
428
+ modelAnswer("structuredOutput", rejectsResponseFormat, (name) => `${name} does not take response_format; asking for JSON in words instead`),
429
+ droppedFieldsAnswer,
430
+ ];
353
431
  /**
354
432
  * Sends a request, re-sending it each time the answer is this endpoint refusing something the
355
433
  * request can do without. Returns once the endpoint has answered, or throws if the refusal is
@@ -386,16 +464,13 @@ const refusesDroppable = (detail, droppable, refused) => droppable.size > 0 &&
386
464
  */
387
465
  export async function negotiate(supports, send, { produced = { any: false }, onNotice, model: name, droppable } = {}) {
388
466
  const optional = new Set(droppable);
389
- // The name and what it has refused, bound together because the notices below need both. They
390
- // exist or are absent as one — the second is resolved from the first — but that is a fact
391
- // about two locals, and narrowing one of those tells TypeScript nothing about the other.
392
467
  const named = name === undefined ? undefined : { name, refused: modelCapabilitiesFor(supports, name) };
393
468
  const model = named?.refused;
394
469
  for (;;) {
395
470
  // What this attempt was built with. `capabilitiesFor` and `modelCapabilitiesFor` hand one
396
471
  // object per endpoint and per model to everyone on them, so a run starting alongside this
397
- // one may latch a flag off while this call is in flight — and the branches below are guarded
398
- // on the flag still being set.
472
+ // one may latch a flag off while this call is in flight — and every answer in `ANSWERS` is
473
+ // guarded on its flag still being set.
399
474
  const sent = flagsOf(supports, model);
400
475
  try {
401
476
  return await send(supports, produced, model);
@@ -403,47 +478,22 @@ export async function negotiate(supports, send, { produced = { any: false }, onN
403
478
  catch (error) {
404
479
  if (produced.any)
405
480
  throw error;
406
- const detail = errorMessage(error);
407
- const stepped = named && planEffortStep(detail, named.refused);
408
- if (supports.strictSchemas && isGrammarError(detail)) {
409
- supports.strictSchemas = false;
410
- onNotice?.("server could not build a grammar; retrying without pattern/format");
411
- }
412
- else if (supports.usageInStream && REJECTS_USAGE.test(detail)) {
413
- supports.usageInStream = false;
414
- onNotice?.("server rejected stream_options; token counts unavailable");
415
- }
416
- else if (named?.refused.reasoningEffort && rejectsEffort(detail)) {
417
- named.refused.reasoningEffort = false;
418
- onNotice?.(`${named.name} does not take a reasoning effort; retrying without one`);
481
+ const refusal = {
482
+ detail: errorMessage(error),
483
+ supports,
484
+ named,
485
+ droppable: optional,
486
+ };
487
+ let notice;
488
+ for (const answer of ANSWERS) {
489
+ notice = answer(refusal);
490
+ if (notice !== undefined)
491
+ break;
419
492
  }
420
- else if (named && stepped) {
421
- named.refused.refusedEfforts.add(stepped.value);
422
- if (stepped.supported)
423
- named.refused.supportedEfforts = stepped.supported;
424
- onNotice?.(`${named.name} does not reason at ${stepped.value}; retrying at ${stepped.next}`);
425
- }
426
- else if (named?.refused.legacyTokenLimit && wantsCompletionLimit(detail)) {
427
- named.refused.legacyTokenLimit = false;
428
- onNotice?.(`${named.name} wants max_completion_tokens; retrying with the limit spelled that way`);
429
- }
430
- else if (named?.refused.chosenTemperature && refusesChosenTemperature(detail)) {
431
- named.refused.chosenTemperature = false;
432
- onNotice?.(`${named.name} takes only its own temperature; retrying without ours`);
433
- }
434
- else if (named?.refused.structuredOutput && rejectsResponseFormat(detail)) {
435
- named.refused.structuredOutput = false;
436
- onNotice?.(`${named.name} does not take response_format; asking for JSON in words instead`);
437
- }
438
- else if (named && refusesDroppable(detail, optional, named.refused)) {
439
- const fields = unknownFields(detail).filter((field) => optional.has(field) && !named.refused.refusedFields.has(field));
440
- for (const field of fields)
441
- named.refused.refusedFields.add(field);
442
- onNotice?.(`${named.name} does not take ${fields.join(", ")}; retrying without ${fields.length > 1 ? "them" : "it"}`);
443
- }
444
- else if (flagsOf(supports, model).every((flag, index) => flag === sent[index])) {
493
+ if (notice !== undefined)
494
+ onNotice?.(notice);
495
+ else if (flagsOf(supports, model).every((flag, index) => flag === sent[index]))
445
496
  throw error;
446
- }
447
497
  // Otherwise the refusal was answered by whoever got there first, and this attempt was
448
498
  // built before the answer existed. Two runs opening on a fresh llama.cpp box both get the
449
499
  // grammar error; the first latches it off and re-sends, and the second used to find the
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import OpenAI from "openai";
3
+ import { isPositive } from "./guards.js";
3
4
  import { scoped } from "./scope.js";
4
5
  /**
5
6
  * The SDK insists on a non-empty key even where the server will not look at it. This is what it
@@ -7,15 +8,14 @@ import { scoped } from "./scope.js";
7
8
  * not — a caller must not let a local endpoint silently borrow the key meant for a paid one.
8
9
  */
9
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);
10
13
  /**
11
14
  * Zero, less, or absent means no limit, which the SDK spells as `undefined`.
12
15
  *
13
16
  * @param config Read for `requestTimeoutSeconds` alone.
14
17
  */
15
- export const timeoutMs = (config) => {
16
- const seconds = config.requestTimeoutSeconds ?? 0;
17
- return seconds > 0 ? seconds * 1000 : undefined;
18
- };
18
+ export const timeoutMs = (config) => limitMs(config.requestTimeoutSeconds ?? 0);
19
19
  /**
20
20
  * A client per endpoint, made once and kept.
21
21
  *
@@ -92,9 +92,8 @@ const evict = () => {
92
92
  */
93
93
  export function configureClients(options = {}) {
94
94
  for (const [name, value] of Object.entries(options)) {
95
- if (typeof value === "number" && value > 0) {
95
+ if (isPositive(value))
96
96
  poolLimits()[name] = value;
97
- }
98
97
  }
99
98
  evict();
100
99
  return { ...poolLimits() };
@@ -111,7 +110,7 @@ export const firstTokenMs = (config) => {
111
110
  const idle = timeoutMs(config);
112
111
  return idle === undefined ? undefined : idle * FIRST_TOKEN_FACTOR;
113
112
  }
114
- return config.firstTokenSeconds > 0 ? config.firstTokenSeconds * 1000 : undefined;
113
+ return limitMs(config.firstTokenSeconds);
115
114
  };
116
115
  /**
117
116
  * The client for an endpoint, built once and kept.
@@ -163,7 +162,7 @@ const CONTEXT_KEYS = [
163
162
  "context_window",
164
163
  "n_ctx",
165
164
  ];
166
- const positive = (value) => (typeof value === "number" && value > 0 ? value : 0);
165
+ const positive = (value) => (isPositive(value) ? value : 0);
167
166
  /**
168
167
  * A listing entry's window, and whether it is only the one the model was trained with.
169
168
  *
@@ -242,6 +241,8 @@ const misses = scoped(() => new Map());
242
241
  * it; see `listings`.
243
242
  */
244
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]);
245
246
  /**
246
247
  * `endpointKey` hashed, for the remembered facts that can leave the process.
247
248
  *
@@ -328,7 +329,7 @@ export async function servedWindow(config) {
328
329
  const endpoint = endpointKey(config);
329
330
  if (unserved().has(endpoint))
330
331
  return 0;
331
- const key = JSON.stringify([endpoint, config.model]);
332
+ const key = modelKey(endpoint, config.model);
332
333
  const known = served().get(key);
333
334
  if (known && (known.window > 0 || Date.now() - known.at < poolLimits().listingMissMs))
334
335
  return known.window;
@@ -424,7 +425,7 @@ export async function contextLimitFor(config, declared = 0) {
424
425
  // context meter and, in a consumer that compacts on it, compaction: the session then runs at
425
426
  // the window instead of under it and fails against the endpoint's own refusal.
426
427
  if (!listed()) {
427
- const missKey = JSON.stringify([key, config.model]);
428
+ const missKey = modelKey(key, config.model);
428
429
  const asked = misses().get(missKey);
429
430
  // Asked again, but not on every call. A model that is never coming answers the same zero
430
431
  // however often the endpoint is asked, and a caller sizing a window per turn pays a round
@@ -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
@@ -175,6 +175,19 @@ export interface RunEvent {
175
175
  * a duplicate `seq`, which is the one thing the sequence is for.
176
176
  */
177
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;
178
191
  /**
179
192
  * Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose
180
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,4 @@
1
+ import { isPositive } from "./guards.js";
1
2
  import { scoped } from "./scope.js";
2
3
  import { LOAD_TOOLS } from "./tool-loading.js";
3
4
  /** The numbers a run of the shape this bus was written for wants. */
@@ -31,11 +32,34 @@ const bus = scoped(() => ({ limits: { ...DEFAULTS }, streams: new Map(), sweepin
31
32
  export function configureEvents(options = {}) {
32
33
  const { limits } = bus();
33
34
  for (const [name, value] of Object.entries(options)) {
34
- if (typeof value === "number" && value > 0)
35
+ if (isPositive(value))
35
36
  limits[name] = value;
36
37
  }
37
38
  return { ...limits };
38
39
  }
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
+ });
39
63
  const streamFor = ({ streams }, runId) => {
40
64
  const existing = streams.get(runId);
41
65
  if (existing)
@@ -114,18 +138,7 @@ export function emit(runId, input) {
114
138
  const stream = streamFor(held, runId);
115
139
  // One clock read, used for both the event and the sweep's bookkeeping.
116
140
  const at = Date.now();
117
- const event = {
118
- at,
119
- text: "",
120
- name: "",
121
- step: "",
122
- ok: null,
123
- usage: null,
124
- ...input,
125
- // After the spread, not before: these are the bus's and a caller does not get a say.
126
- runId,
127
- seq: ++stream.seq,
128
- };
141
+ const event = stamp(input, runId, ++stream.seq, at);
129
142
  stream.events.push(event);
130
143
  stream.touched = at;
131
144
  if (stream.events.length > limits.maxEvents + limits.trimSlack) {
@@ -228,17 +241,11 @@ async function* watching(held, runId, signal) {
228
241
  // Inside the gap there is nothing to collide with: those seqs reach no watcher.
229
242
  const gap = dropped;
230
243
  dropped = 0;
231
- yield {
232
- runId,
233
- seq: event.seq - 1,
234
- at: event.at,
244
+ yield stamp({
235
245
  kind: "notice",
236
246
  text: `${gap} event(s) dropped: this watcher fell too far behind`,
237
- name: "",
238
247
  step: event.step,
239
- ok: null,
240
- usage: null,
241
- };
248
+ }, runId, event.seq - 1, event.at);
242
249
  }
243
250
  yield event;
244
251
  // `done` is the last event a run will ever have, so the subscription completes rather
@@ -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;
package/dist/guards.js ADDED
@@ -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 const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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 const isPositive = (value) => typeof value === "number" && value > 0;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The points in a session a hook can be bound to, apart from everything that runs one.
3
+ *
4
+ * A leaf with no imports, because two modules need these lists and only one of them may load
5
+ * `node:crypto`: `hooks.ts` hashes a turn into the uuid a memory server dedups on, and `spec.ts`
6
+ * has to stay importable in a browser. Before this each kept its own copy, with a test holding
7
+ * one pair together and nothing holding the other.
8
+ */
9
+ /**
10
+ * A point in a session a hook can be bound to. Named after Claude Code's hooks of the same shape
11
+ * — `sessionStart` (SessionStart), `beforeTurn` (UserPromptSubmit), `afterTurn` (Stop),
12
+ * `beforeCompact` (PreCompact), `sessionEnd` (SessionEnd) — plus `sessionDelete`, for when the host
13
+ * deletes a session's record.
14
+ */
15
+ export type HookEvent = "sessionStart" | "beforeTurn" | "afterTurn" | "beforeCompact" | "sessionEnd" | "sessionDelete";
16
+ /** Every event a hook can be bound to, in the order a session meets them. */
17
+ export declare const HOOK_EVENTS: readonly HookEvent[];
18
+ /**
19
+ * The events whose hooks run before a request, and so the only ones whose output can reach it.
20
+ * Anything later runs once the model has already answered.
21
+ */
22
+ export declare const INJECT_EVENTS: ReadonlySet<HookEvent>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The points in a session a hook can be bound to, apart from everything that runs one.
3
+ *
4
+ * A leaf with no imports, because two modules need these lists and only one of them may load
5
+ * `node:crypto`: `hooks.ts` hashes a turn into the uuid a memory server dedups on, and `spec.ts`
6
+ * has to stay importable in a browser. Before this each kept its own copy, with a test holding
7
+ * one pair together and nothing holding the other.
8
+ */
9
+ /** Every event a hook can be bound to, in the order a session meets them. */
10
+ export const HOOK_EVENTS = [
11
+ "sessionStart",
12
+ "beforeTurn",
13
+ "afterTurn",
14
+ "beforeCompact",
15
+ "sessionEnd",
16
+ "sessionDelete",
17
+ ];
18
+ /**
19
+ * The events whose hooks run before a request, and so the only ones whose output can reach it.
20
+ * Anything later runs once the model has already answered.
21
+ */
22
+ export const INJECT_EVENTS = new Set(["sessionStart", "beforeTurn"]);