@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/side-task.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import OpenAI from "openai";
2
- import { capabilitiesFor, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
2
+ import { capabilitiesFor, ceilingAndTemperature, effortFor, modelCapabilitiesFor, negotiate, } from "./capabilities.js";
3
3
  import { endpointId, getClient } from "./client.js";
4
4
  import { errorMessage } from "./errors.js";
5
- import { isTransient } from "./retry.js";
6
- import { relaxTools, sanitizeTools } from "./schema-compat.js";
5
+ import { refusesRequest } from "./retry.js";
6
+ import { relaxSchema, sanitizeSchema } from "./schema-compat.js";
7
+ import { scoped } from "./scope.js";
7
8
  import { stripThinking } from "./thinking.js";
9
+ import { looseJson } from "./tool-calls.js";
8
10
  /**
9
11
  * One-shot calls that support a run without being one: picking tools, naming a session,
10
12
  * summarising a transcript, proposing follow-ups. They share a shape — small prompt, short
@@ -39,32 +41,13 @@ const NO_THINKING = { chat_template_kwargs: { enable_thinking: false } };
39
41
  * Only the `chat_template_kwargs` half is here. `reasoning_effort` is `negotiate`'s to latch, on
40
42
  * the same (endpoint, model) pair, where a run on that model can read it too.
41
43
  */
42
- const noHints = new Set();
44
+ const noHints = scoped(() => new Set());
43
45
  /** An (endpoint, model) pair as `noHints` holds it: `[endpointId, model]`, stringified. */
44
46
  export const hintKey = (endpoint, model) => JSON.stringify([endpoint, model]);
45
47
  /** The pairs that refused the hints, the live set, for `exportCapabilities` and `importCapabilities`. */
46
- export const refusedHints = () => noHints;
48
+ export const refusedHints = () => noHints();
47
49
  /** Test seam, alongside `resetClients` and `resetAll`: forget which models refused the hints. */
48
- export const resetHints = () => noHints.clear();
49
- /**
50
- * Whether a failure is the server complaining about the request, rather than failing to answer.
51
- *
52
- * The retry below used to catch everything, so an aborted first call — or a connection that
53
- * never landed — latched the hints off for the life of the process and every later side task
54
- * paid for it by burning a whole budget on deliberation. Narrowing that to any 4xx was still
55
- * too wide: 401, 404 and 429 are all 4xx and none of them is about the fields. A 429 was the
56
- * worst of them, because the retry then re-sent the whole request immediately — doubling the
57
- * rate against a server that had just asked for less of it — and `isTransient` accepts exactly
58
- * that status, so the two halves of this package disagreed about one error.
59
- *
60
- * 400 and 422 are what a server says when it read the body and disliked it. Everything else
61
- * is left to the caller's own retry.
62
- */
63
- function rejectedTheRequest(error) {
64
- if (!(error instanceof OpenAI.APIError) || isTransient(error))
65
- return false;
66
- return error.status === 400 || error.status === 422;
67
- }
50
+ export const resetHints = () => noHints().clear();
68
51
  /**
69
52
  * Runs a side task and returns the reply text, thinking stripped. Throws like any request.
70
53
  *
@@ -97,16 +80,10 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
97
80
  return getClient(config).chat.completions.create({
98
81
  model,
99
82
  // The reasoning models want the ceiling spelled the other way, and they are exactly the
100
- // models a side task most wants to stop deliberating. Zero is no ceiling, as it is on the
101
- // main model: a spec's `maxTokens: 0` sent as `max_tokens: 0` asks for an empty reply.
102
- ...(maxTokens > 0
103
- ? refused && !refused.legacyTokenLimit
104
- ? { max_completion_tokens: maxTokens }
105
- : { max_tokens: maxTokens }
106
- : {}),
107
- // One that will only run at the temperature it was built with is sent none: a side task
108
- // wants the same answer twice, and 1.0 from that model is as close as it gets.
109
- ...(refused && !refused.chosenTemperature ? {} : { temperature }),
83
+ // models a side task most wants to stop deliberating. One that will only run at its own
84
+ // temperature is sent none: a side task wants the same answer twice, and 1.0 from that
85
+ // model is as close as it gets.
86
+ ...ceilingAndTemperature(refused, maxTokens, temperature),
110
87
  messages: [
111
88
  { role: "system", content: system },
112
89
  { role: "user", content: user },
@@ -130,7 +107,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
130
107
  const key = hintKey(endpointId(config), model);
131
108
  // A chosen effort replaces the template hint rather than riding beside it: `enable_thinking:
132
109
  // false` next to `reasoning_effort: "high"` is a request that contradicts itself.
133
- const hints = !level && !noHints.has(key);
110
+ const hints = !level && !noHints().has(key);
134
111
  let response;
135
112
  try {
136
113
  response = await attempt(hints, true);
@@ -141,7 +118,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
141
118
  // does not offer as `none`. Or a 400 about something else entirely, which is why the
142
119
  // notice says what was tried rather than what was wrong.
143
120
  const effort = sentEffort;
144
- if (!(hints || effort) || !rejectedTheRequest(error))
121
+ if (!(hints || effort) || !refusesRequest(error))
145
122
  throw error;
146
123
  onNotice?.(level
147
124
  ? `${model} rejected a request carrying a reasoning effort; retrying without it`
@@ -153,7 +130,7 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
153
130
  // cannot latch is blamed; if it was the effort after all, the next call is left with only
154
131
  // the effort to drop and latches that instead.
155
132
  if (hints)
156
- noHints.add(key);
133
+ noHints().add(key);
157
134
  else
158
135
  modelCapabilitiesFor(supports, model).reasoningEffort = false;
159
136
  }
@@ -188,13 +165,8 @@ async function complete(config, model, system, user, { maxTokens = 512, temperat
188
165
  * @param options A side task's options, plus the schema's `name` and whether it is `strict`.
189
166
  */
190
167
  export async function askJson(config, model, system, user, schema, { name = "answer", strict = true, ...options } = {}) {
191
- const tool = (parameters) => ({
192
- type: "function",
193
- function: { name, parameters },
194
- });
195
- const [sanitized] = sanitizeTools([tool(schema)]);
196
- const shapeOf = (definition) => definition?.type === "function" ? (definition.function.parameters ?? {}) : {};
197
- const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(shapeOf(sanitized))}`;
168
+ const sanitized = sanitizeSchema(schema);
169
+ const instruction = `${system}\n\nReply with JSON alone, matching this JSON Schema:\n${JSON.stringify(sanitized)}`;
198
170
  const reply = await complete(config, model, instruction, user, options, (supports, refused) => refused.structuredOutput
199
171
  ? {
200
172
  response_format: {
@@ -202,7 +174,7 @@ export async function askJson(config, model, system, user, schema, { name = "ans
202
174
  json_schema: {
203
175
  name,
204
176
  strict,
205
- schema: shapeOf(supports.strictSchemas ? sanitized : relaxTools([sanitized])[0]),
177
+ schema: supports.strictSchemas ? sanitized : relaxSchema(sanitized),
206
178
  },
207
179
  },
208
180
  }
@@ -234,6 +206,11 @@ export async function tryAsk(label, run, { onNotice } = {}) {
234
206
  * Models are asked for JSON and often answer with prose around it, or a fenced block. Pull out
235
207
  * the first array or object rather than failing the task over a wrapper.
236
208
  *
209
+ * What is pulled out is read the way a tool call's arguments are: as written where that parses,
210
+ * and otherwise with the almost-JSON a local model writes repaired — single quotes, `True` and
211
+ * `None`, bare keys, a trailing comma. A small model asked for a list gets these wrong as often in
212
+ * a reply as in a call, and a side task that fails over one costs the run a second request.
213
+ *
237
214
  * @param text The reply, fences and prose included. Nothing parseable gives `undefined`.
238
215
  */
239
216
  export function parseJson(text) {
@@ -245,12 +222,7 @@ export function parseJson(text) {
245
222
  const end = Math.max(body.lastIndexOf("]"), body.lastIndexOf("}"));
246
223
  if (end <= start)
247
224
  return undefined;
248
- try {
249
- return JSON.parse(body.slice(start, end + 1));
250
- }
251
- catch {
252
- return undefined;
253
- }
225
+ return looseJson(body.slice(start, end + 1));
254
226
  }
255
227
  /**
256
228
  * Strips the quoting and list punctuation models decorate short answers with.
@@ -16,8 +16,8 @@ export interface ModelSnapshot {
16
16
  refusedFields: string[];
17
17
  structuredOutput: boolean;
18
18
  /**
19
- * Continues a trailing assistant message. Absent in a snapshot taken before it was latched, which
20
- * reads as not refused.
19
+ * Continues a trailing assistant message. Always written; a stored snapshot from before it was
20
+ * latched lacks it, which `importCapabilities` reads as not refused.
21
21
  */
22
22
  assistantPrefill: boolean;
23
23
  /** Takes the no-thinking hints `ask` sends. */
package/dist/snapshot.js CHANGED
@@ -1,4 +1,5 @@
1
- import { capabilitiesById, knownCapabilities, modelCapabilitiesFor } from "./capabilities.js";
1
+ import { capabilitiesById, knownCapabilities, MODEL_FLAGS, modelCapabilitiesFor, OPTIMISTIC_MODEL, } from "./capabilities.js";
2
+ import { isRecord } from "./guards.js";
2
3
  import { hintKey, refusedHints } from "./side-task.js";
3
4
  /**
4
5
  * What endpoints and models refused, carried across a restart.
@@ -11,23 +12,23 @@ import { hintKey, refusedHints } from "./side-task.js";
11
12
  /** The version `importCapabilities` accepts. Raised when what is latched changes shape. */
12
13
  export const CAPABILITY_SNAPSHOT_VERSION = 1;
13
14
  const optimisticModel = () => ({
14
- reasoningEffort: true,
15
- legacyTokenLimit: true,
16
- chosenTemperature: true,
17
- refusedFields: [],
18
- structuredOutput: true,
19
- assistantPrefill: true,
15
+ ...OPTIMISTIC_MODEL,
20
16
  thinkingHints: true,
17
+ refusedFields: [],
21
18
  refusedEfforts: [],
22
19
  });
23
- const refusedAnything = (model) => !model.reasoningEffort ||
24
- !model.legacyTokenLimit ||
25
- !model.chosenTemperature ||
20
+ const refusedAnything = (model) => MODEL_FLAGS.some((flag) => !model[flag]) ||
26
21
  !model.thinkingHints ||
27
- !model.structuredOutput ||
28
- !model.assistantPrefill ||
29
22
  model.refusedFields.length > 0 ||
30
23
  (model.refusedEfforts?.length ?? 0) > 0;
24
+ /** Latches every string in a stored list into a live set, skipping what is not a list of them. */
25
+ const latchInto = (held, stored) => {
26
+ if (!Array.isArray(stored))
27
+ return;
28
+ for (const value of stored)
29
+ if (typeof value === "string")
30
+ held.add(value);
31
+ };
31
32
  /**
32
33
  * Every refusal this process has latched, as a JSON-safe blob to store and hand back on boot.
33
34
  *
@@ -53,19 +54,15 @@ export function exportCapabilities() {
53
54
  for (const [id, supports] of knownCapabilities()) {
54
55
  const models = {};
55
56
  for (const [name, refused] of supports.models) {
56
- const model = {
57
- ...optimisticModel(),
58
- reasoningEffort: refused.reasoningEffort,
59
- legacyTokenLimit: refused.legacyTokenLimit,
60
- chosenTemperature: refused.chosenTemperature,
61
- refusedFields: [...refused.refusedFields].sort(),
62
- structuredOutput: refused.structuredOutput,
63
- assistantPrefill: refused.assistantPrefill,
64
- refusedEfforts: [...refused.refusedEfforts].sort(),
65
- // Only alongside a refusal, since on its own a published list latches nothing: the model
66
- // named it while refusing a rung, and that rung is in `refusedEfforts`.
67
- ...(refused.supportedEfforts ? { supportedEfforts: [...refused.supportedEfforts] } : {}),
68
- };
57
+ const model = optimisticModel();
58
+ for (const flag of MODEL_FLAGS)
59
+ model[flag] = refused[flag];
60
+ model.refusedFields = [...refused.refusedFields].sort();
61
+ model.refusedEfforts = [...refused.refusedEfforts].sort();
62
+ // Only alongside a refusal, since on its own a published list latches nothing: the model
63
+ // named it while refusing a rung, and that rung is in `refusedEfforts`.
64
+ if (refused.supportedEfforts)
65
+ model.supportedEfforts = [...refused.supportedEfforts];
69
66
  if (refusedAnything(model))
70
67
  models[name] = model;
71
68
  }
@@ -85,7 +82,6 @@ export function exportCapabilities() {
85
82
  }
86
83
  return { version: CAPABILITY_SNAPSHOT_VERSION, savedAt: new Date().toISOString(), endpoints };
87
84
  }
88
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
89
85
  /**
90
86
  * Latches what a stored snapshot says was refused, on top of whatever this process has learned.
91
87
  *
@@ -124,22 +120,10 @@ export function importCapabilities(snapshot) {
124
120
  if (!isRecord(model))
125
121
  continue;
126
122
  const refused = modelCapabilitiesFor(supports, name);
127
- if (model.reasoningEffort === false)
128
- refused.reasoningEffort = false;
129
- if (model.legacyTokenLimit === false)
130
- refused.legacyTokenLimit = false;
131
- if (model.chosenTemperature === false)
132
- refused.chosenTemperature = false;
133
- if (model.structuredOutput === false)
134
- refused.structuredOutput = false;
135
- if (model.assistantPrefill === false)
136
- refused.assistantPrefill = false;
137
- if (Array.isArray(model.refusedEfforts)) {
138
- for (const effort of model.refusedEfforts) {
139
- if (typeof effort === "string")
140
- refused.refusedEfforts.add(effort);
141
- }
142
- }
123
+ for (const flag of MODEL_FLAGS)
124
+ if (model[flag] === false)
125
+ refused[flag] = false;
126
+ latchInto(refused.refusedEfforts, model.refusedEfforts);
143
127
  // Replaced rather than merged: two lists of what one model takes are two readings of the
144
128
  // same fact, and the stored one is at least as recent as an empty absent.
145
129
  if (Array.isArray(model.supportedEfforts)) {
@@ -147,12 +131,7 @@ export function importCapabilities(snapshot) {
147
131
  if (listed.length)
148
132
  refused.supportedEfforts = listed;
149
133
  }
150
- if (Array.isArray(model.refusedFields)) {
151
- for (const field of model.refusedFields) {
152
- if (typeof field === "string")
153
- refused.refusedFields.add(field);
154
- }
155
- }
134
+ latchInto(refused.refusedFields, model.refusedFields);
156
135
  if (model.thinkingHints === false)
157
136
  refusedHints().add(hintKey(id, name));
158
137
  }
package/dist/spec.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentConfig, Endpoint } from "./config.ts";
2
- import type { HookEvent } from "./hooks.ts";
2
+ import { type HookEvent } from "./hook-events.ts";
3
3
  /**
4
4
  * A JSON document that defines an agent, and the rules for reading one.
5
5
  *
@@ -10,11 +10,12 @@ import type { HookEvent } from "./hooks.ts";
10
10
  * real ceiling of zero* in the third. This is the shape they can all write and all read, argued
11
11
  * out in `docs/agent-spec.md` before any of it was code.
12
12
  *
13
- * Nothing else in `src/` imports this module, and this module imports nothing but types. That is
14
- * the same seam `config.ts` draws — the loop takes the parts it reads, not a config object — and
15
- * it is what lets the document travel between hosts without the loop growing an opinion about
16
- * where an agent comes from. It is also why this is importable in a browser, where `client.ts`
17
- * and `hooks.ts` are not: a host validating a pasted document in a form needs no `node:crypto`.
13
+ * Nothing else in `src/` imports this module, and this module imports nothing that needs Node:
14
+ * types, and leaves with no imports of their own. That is the same seam `config.ts` draws — the
15
+ * loop takes the parts it reads, not a config object — and it is what lets the document travel
16
+ * between hosts without the loop growing an opinion about where an agent comes from. It is also
17
+ * why this is importable in a browser, where `client.ts` and `hooks.ts` are not: a host validating
18
+ * a pasted document in a form needs no `node:crypto`.
18
19
  */
19
20
  /** The token a document declares itself with. A loader refuses anything else. */
20
21
  export declare const AGENT_SPEC = "cubicecho.agent/1";
@@ -29,12 +30,11 @@ export declare const AGENT_SPEC_VERSION = 1;
29
30
  */
30
31
  export declare const AGENT_TASKS: readonly ["compaction", "toolSelect", "title", "followups"];
31
32
  /**
32
- * Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
33
+ * Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
33
34
  *
34
- * `hooks.ts` owns the list; importing its `HOOK_EVENTS` would pull the whole module, and its
35
- * `createHash` with it, into a file whose whole point is that a browser can load it. The `HookEvent`
36
- * type is imported and erased, so a removed event fails to compile here, and a test asserts this
37
- * list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
35
+ * The same array, not a copy. It comes from `hook-events.ts` rather than `hooks.ts` because that
36
+ * module's `createHash` would otherwise come with it, into a file whose whole point is that a
37
+ * browser can load it.
38
38
  */
39
39
  export declare const SPEC_EVENTS: readonly HookEvent[];
40
40
  /** One agent, as a document. Every field but `spec` is optional, because a layer says only what it changes. */
@@ -163,7 +163,6 @@ export interface AgentHook {
163
163
  /** `false` keeps the hook in the document without running it. */
164
164
  enabled?: boolean;
165
165
  }
166
- /** One bundled MCP server, as far as this package reads it. */
167
166
  /** A bundled MCP server, validated only as far as a round trip needs and otherwise passed through. */
168
167
  export interface SpecServer {
169
168
  id?: string;
package/dist/spec.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { isRecord } from "./guards.js";
2
+ import { HOOK_EVENTS, INJECT_EVENTS } from "./hook-events.js";
1
3
  /**
2
4
  * A JSON document that defines an agent, and the rules for reading one.
3
5
  *
@@ -8,11 +10,12 @@
8
10
  * real ceiling of zero* in the third. This is the shape they can all write and all read, argued
9
11
  * out in `docs/agent-spec.md` before any of it was code.
10
12
  *
11
- * Nothing else in `src/` imports this module, and this module imports nothing but types. That is
12
- * the same seam `config.ts` draws — the loop takes the parts it reads, not a config object — and
13
- * it is what lets the document travel between hosts without the loop growing an opinion about
14
- * where an agent comes from. It is also why this is importable in a browser, where `client.ts`
15
- * and `hooks.ts` are not: a host validating a pasted document in a form needs no `node:crypto`.
13
+ * Nothing else in `src/` imports this module, and this module imports nothing that needs Node:
14
+ * types, and leaves with no imports of their own. That is the same seam `config.ts` draws — the
15
+ * loop takes the parts it reads, not a config object — and it is what lets the document travel
16
+ * between hosts without the loop growing an opinion about where an agent comes from. It is also
17
+ * why this is importable in a browser, where `client.ts` and `hooks.ts` are not: a host validating
18
+ * a pasted document in a form needs no `node:crypto`.
16
19
  */
17
20
  /** The token a document declares itself with. A loader refuses anything else. */
18
21
  export const AGENT_SPEC = "cubicecho.agent/1";
@@ -27,23 +30,13 @@ export const AGENT_SPEC_VERSION = 1;
27
30
  */
28
31
  export const AGENT_TASKS = ["compaction", "toolSelect", "title", "followups"];
29
32
  /**
30
- * Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
33
+ * Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
31
34
  *
32
- * `hooks.ts` owns the list; importing its `HOOK_EVENTS` would pull the whole module, and its
33
- * `createHash` with it, into a file whose whole point is that a browser can load it. The `HookEvent`
34
- * type is imported and erased, so a removed event fails to compile here, and a test asserts this
35
- * list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
35
+ * The same array, not a copy. It comes from `hook-events.ts` rather than `hooks.ts` because that
36
+ * module's `createHash` would otherwise come with it, into a file whose whole point is that a
37
+ * browser can load it.
36
38
  */
37
- export const SPEC_EVENTS = [
38
- "sessionStart",
39
- "beforeTurn",
40
- "afterTurn",
41
- "beforeCompact",
42
- "sessionEnd",
43
- "sessionDelete",
44
- ];
45
- /** The events whose output can still reach a request, so the only ones `inject` means anything on. */
46
- const SPEC_INJECT = new Set(["sessionStart", "beforeTurn"]);
39
+ export const SPEC_EVENTS = HOOK_EVENTS;
47
40
  /** Body fields the loop owns. `buildBody` would overwrite them anyway; better to say so on import. */
48
41
  const RESERVED_BODY = ["model", "messages", "stream", "tools"];
49
42
  /**
@@ -62,7 +55,6 @@ export const RESOLVED_DEFAULTS = {
62
55
  maxToolIterations: 20,
63
56
  maxRetries: 0,
64
57
  };
65
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
66
58
  /** Absent and null are the same thing: a nullable column round-trips through JSON as `null`. */
67
59
  const absent = (value) => value === undefined || value === null;
68
60
  /** A `${VAR}`-shaped string, which is kept as the literal it is rather than expanded. */
@@ -138,6 +130,12 @@ function parseEndpoint(report, path, value) {
138
130
  report.drop(`${path}.apiKey`, "is not part of this format — a document carries no credentials");
139
131
  return said(endpoint) ? endpoint : undefined;
140
132
  }
133
+ /** The three fields a model and a side task both carry, held to the same bounds in each. */
134
+ const parseSampling = (report, path, value) => ({
135
+ maxTokens: report.number(`${path}.maxTokens`, value.maxTokens, 0),
136
+ temperature: report.number(`${path}.temperature`, value.temperature, 0, 2),
137
+ reasoningEffort: report.string(`${path}.reasoningEffort`, value.reasoningEffort),
138
+ });
141
139
  function parseModel(report, value) {
142
140
  if (absent(value))
143
141
  return undefined;
@@ -159,9 +157,7 @@ function parseModel(report, value) {
159
157
  }
160
158
  return kept({
161
159
  model: report.string("model.model", value.model),
162
- maxTokens: report.number("model.maxTokens", value.maxTokens, 0),
163
- temperature: report.number("model.temperature", value.temperature, 0, 2),
164
- reasoningEffort: report.string("model.reasoningEffort", value.reasoningEffort),
160
+ ...parseSampling(report, "model", value),
165
161
  contextLength: report.number("model.contextLength", value.contextLength, 0),
166
162
  extraBody,
167
163
  });
@@ -262,9 +258,7 @@ function parseTasks(report, value) {
262
258
  tasks[key] = kept({
263
259
  model: report.string(`${path}.model`, held.model),
264
260
  endpoint: parseEndpoint(report, `${path}.endpoint`, held.endpoint),
265
- maxTokens: report.number(`${path}.maxTokens`, held.maxTokens, 0),
266
- temperature: report.number(`${path}.temperature`, held.temperature, 0, 2),
267
- reasoningEffort: report.string(`${path}.reasoningEffort`, held.reasoningEffort),
261
+ ...parseSampling(report, path, held),
268
262
  });
269
263
  }
270
264
  return tasks;
@@ -294,7 +288,7 @@ function parseHooks(report, value, events) {
294
288
  if (!events.includes(on))
295
289
  report.note(`${path}.on`, `"${on}" is never fired by this host`);
296
290
  let inject = report.boolean(`${path}.inject`, held.inject);
297
- if (inject && !SPEC_INJECT.has(on)) {
291
+ if (inject && !INJECT_EVENTS.has(on)) {
298
292
  report.drop(`${path}.inject`, `"${on}" runs after the model has already answered`);
299
293
  inject = undefined;
300
294
  }
@@ -480,20 +474,7 @@ export function parseSpec(document, options = {}) {
480
474
  };
481
475
  }
482
476
  /** Merges the layers that named a key, last one winning, and leaves it absent if none did. */
483
- function mergeEndpoints(layers) {
484
- const out = {};
485
- for (const layer of layers) {
486
- if (!layer)
487
- continue;
488
- if (layer.baseUrl !== undefined)
489
- out.baseUrl = layer.baseUrl;
490
- if (layer.requestTimeoutSeconds !== undefined)
491
- out.requestTimeoutSeconds = layer.requestTimeoutSeconds;
492
- if (layer.firstTokenSeconds !== undefined)
493
- out.firstTokenSeconds = layer.firstTokenSeconds;
494
- }
495
- return out;
496
- }
477
+ const mergeLayers = (layers) => Object.assign({}, ...layers.map((layer) => kept(layer ?? {})));
497
478
  /** An `Endpoint` as the loop takes one. `apiKey` is always empty; see `resolveAgentSpec`. */
498
479
  const asEndpoint = (spec) => ({
499
480
  baseUrl: spec.baseUrl ?? "",
@@ -524,7 +505,7 @@ const asEndpoint = (spec) => ({
524
505
  * @param layers The documents, weakest first: settings, then the agent, then a task, then a step.
525
506
  */
526
507
  export function resolveAgentSpec(layers) {
527
- const endpoint = mergeEndpoints(layers.map((layer) => layer.endpoint));
508
+ const endpoint = mergeLayers(layers.map((layer) => layer.endpoint));
528
509
  const model = {};
529
510
  const extraBody = {};
530
511
  for (const layer of layers) {
@@ -541,31 +522,20 @@ export function resolveAgentSpec(layers) {
541
522
  for (const layer of layers)
542
523
  for (const part of layer.prompt ?? [])
543
524
  parts.set(part.id, { ...(parts.get(part.id) ?? { id: part.id }), ...part });
544
- const tools = {};
545
- for (const layer of layers) {
546
- if (layer.tools?.discovery !== undefined)
547
- tools.discovery = layer.tools.discovery;
548
- if (layer.tools?.maxIterations !== undefined)
549
- tools.maxIterations = layer.tools.maxIterations;
550
- // Replaced whole rather than unioned: a list, an empty list and no list are three answers,
551
- // and a union of the first two is the first, which loses the scoping the operator asked for.
552
- if (layer.tools?.servers !== undefined)
553
- tools.servers = [...layer.tools.servers];
554
- }
555
- const retry = {};
556
- for (const layer of layers) {
557
- if (layer.retry?.maxRetries !== undefined)
558
- retry.maxRetries = layer.retry.maxRetries;
559
- if (layer.retry?.loadingTimeoutSeconds !== undefined)
560
- retry.loadingTimeoutSeconds = layer.retry.loadingTimeoutSeconds;
561
- }
525
+ // `servers` is replaced whole rather than unioned: a list, an empty list and no list are three
526
+ // answers, and a union of the first two is the first, which loses the scoping the operator
527
+ // asked for. Copied, so the resolved agent does not share an array with the layer it came from.
528
+ const tools = mergeLayers(layers.map((layer) => layer.tools));
529
+ if (tools.servers)
530
+ tools.servers = [...tools.servers];
531
+ const retry = mergeLayers(layers.map((layer) => layer.retry));
562
532
  const merged = new Map();
563
533
  for (const layer of layers)
564
534
  for (const [key, task] of Object.entries(layer.tasks ?? {}))
565
535
  merged.set(key, {
566
536
  ...(merged.get(key) ?? {}),
567
537
  ...task,
568
- endpoint: mergeEndpoints([merged.get(key)?.endpoint, task.endpoint]),
538
+ endpoint: mergeLayers([merged.get(key)?.endpoint, task.endpoint]),
569
539
  });
570
540
  const tasks = {};
571
541
  for (const [key, task] of merged) {
@@ -575,7 +545,7 @@ export function resolveAgentSpec(layers) {
575
545
  continue;
576
546
  tasks[key] = kept({
577
547
  model: task.model,
578
- endpoint: asEndpoint(mergeEndpoints([endpoint, task.endpoint])),
548
+ endpoint: asEndpoint(mergeLayers([endpoint, task.endpoint])),
579
549
  maxTokens: task.maxTokens,
580
550
  temperature: task.temperature,
581
551
  reasoningEffort: task.reasoningEffort,
@@ -599,9 +569,9 @@ export function resolveAgentSpec(layers) {
599
569
  for (const layer of layers)
600
570
  for (const key of layer.requires ?? [])
601
571
  requires.add(key);
602
- const id = [...layers].reverse().find((layer) => layer.id)?.id ?? "";
603
- const name = [...layers].reverse().find((layer) => layer.name)?.name;
604
- const description = [...layers].reverse().find((layer) => layer.description)?.description;
572
+ const id = layers.findLast((layer) => layer.id)?.id ?? "";
573
+ const name = layers.findLast((layer) => layer.name)?.name;
574
+ const description = layers.findLast((layer) => layer.description)?.description;
605
575
  const prompt = [...parts.values()];
606
576
  return {
607
577
  id,
package/dist/stream.d.ts CHANGED
@@ -107,6 +107,13 @@ export interface TurnUsage {
107
107
  */
108
108
  context?: ContextBreakdown;
109
109
  }
110
+ /**
111
+ * A usage with nothing counted yet, a fresh object each call since every caller adds into it.
112
+ *
113
+ * Only the four counts every turn has: the optional fields stay absent, which is what says
114
+ * nothing reported them.
115
+ */
116
+ export declare const noUsage: () => TurnUsage;
110
117
  /** One streamed turn, put back together into the shape a loop and a transcript work with. */
111
118
  export interface Turn {
112
119
  content: string;
package/dist/stream.js CHANGED
@@ -1,5 +1,12 @@
1
1
  import { EndpointSilent } from "./retry.js";
2
2
  import { DEFAULT_FENCES, FenceSplitter } from "./thinking.js";
3
+ /**
4
+ * A usage with nothing counted yet, a fresh object each call since every caller adds into it.
5
+ *
6
+ * Only the four counts every turn has: the optional fields stay absent, which is what says
7
+ * nothing reported them.
8
+ */
9
+ export const noUsage = () => ({ prompt: 0, completion: 0, total: 0, cached: 0 });
3
10
  /**
4
11
  * The fields of `timings` a turn reports, under the names `TurnUsage` gives them. A value that is
5
12
  * not a finite number is left out rather than guessed at.
@@ -91,7 +98,7 @@ export async function streamTurn(client, body, { signal, idleMs, firstChunkMs, p
91
98
  // In arrival order, sorted by index at the end; a call from a server that sent none keeps
92
99
  // its place in the order they arrived.
93
100
  const calls = [];
94
- const usage = { prompt: 0, completion: 0, total: 0, cached: 0 };
101
+ const usage = noUsage();
95
102
  // The usage block's own cache count wins over llama.cpp's, which is the same number in the
96
103
  // servers that send both; kept apart so the order the two arrive in does not decide.
97
104
  let cacheReport;
package/dist/thinking.js CHANGED
@@ -110,11 +110,14 @@ export class FenceSplitter {
110
110
  this.#held = "";
111
111
  return parts;
112
112
  }
113
+ /** The markers worth looking for: only the close inside a fence, every one outside it. */
114
+ #candidates() {
115
+ return this.#inside ? [this.#inside.close] : this.#markers;
116
+ }
113
117
  /** The earliest marker that means something in the current state. */
114
118
  #next(text) {
115
- const candidates = this.#inside ? [this.#inside.close] : this.#markers;
116
119
  let best;
117
- for (const marker of candidates) {
120
+ for (const marker of this.#candidates()) {
118
121
  const at = text.indexOf(marker);
119
122
  if (at === -1)
120
123
  continue;
@@ -125,9 +128,8 @@ export class FenceSplitter {
125
128
  }
126
129
  /** How much of the end of `text` could be the beginning of a marker. */
127
130
  #partialTail(text) {
128
- const candidates = this.#inside ? [this.#inside.close] : this.#markers;
129
131
  let keep = 0;
130
- for (const marker of candidates)
132
+ for (const marker of this.#candidates())
131
133
  for (let length = Math.min(marker.length - 1, text.length); length > keep; length--)
132
134
  if (text.endsWith(marker.slice(0, length))) {
133
135
  keep = length;
package/dist/tokens.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The characters one token is taken to be, where nothing has measured it.
3
+ *
4
+ * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
5
+ * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
6
+ */
7
+ export declare const CHARS_PER_TOKEN = 4;
1
8
  /**
2
9
  * Rough token count. Characters over four, because there is no tokenizer here and there is not
3
10
  * going to be one: a server that will not say how big its window is will not lend us its
@@ -10,8 +17,8 @@
10
17
  *
11
18
  * Its own module because it is the one number several of these agree on, and the module that
12
19
  * owns it should not be one that also does something. It was extracted from `side-task` to
13
- * break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
14
- * sizing its own prompt still do, and a leaf with no imports is the right home for it.
20
+ * break a cycle with `retry`; neither reads it now, but `hooks` does to cap a context block, as
21
+ * does any consumer sizing its own prompt, and a leaf with no imports is the right home for it.
15
22
  *
16
23
  * @param text Prose or serialised JSON — both counted the same way, which is why JSON reads low.
17
24
  */
package/dist/tokens.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * The characters one token is taken to be, where nothing has measured it.
3
+ *
4
+ * The fallback, not the rule: once a turn has come back with a reported prompt count, `runTurn`
5
+ * divides by what that endpoint's model was measured at instead. See `charsPerTokenFor`.
6
+ */
7
+ export const CHARS_PER_TOKEN = 4;
1
8
  /**
2
9
  * Rough token count. Characters over four, because there is no tokenizer here and there is not
3
10
  * going to be one: a server that will not say how big its window is will not lend us its
@@ -10,9 +17,9 @@
10
17
  *
11
18
  * Its own module because it is the one number several of these agree on, and the module that
12
19
  * owns it should not be one that also does something. It was extracted from `side-task` to
13
- * break a cycle with `retry`; `side-task` no longer reads it, but `retry` and any consumer
14
- * sizing its own prompt still do, and a leaf with no imports is the right home for it.
20
+ * break a cycle with `retry`; neither reads it now, but `hooks` does to cap a context block, as
21
+ * does any consumer sizing its own prompt, and a leaf with no imports is the right home for it.
15
22
  *
16
23
  * @param text Prose or serialised JSON — both counted the same way, which is why JSON reads low.
17
24
  */
18
- export const estimateTokens = (text) => Math.ceil(text.length / 4);
25
+ export const estimateTokens = (text) => Math.ceil(text.length / CHARS_PER_TOKEN);