@cubicecho/agent-core 2.23.0 → 2.25.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 (71) hide show
  1. package/README.md +14 -9
  2. package/dist/agent-loop.d.ts +84 -204
  3. package/dist/agent-loop.js +416 -643
  4. package/dist/calibration.d.ts +14 -10
  5. package/dist/calibration.js +30 -22
  6. package/dist/capabilities.d.ts +140 -35
  7. package/dist/capabilities.js +345 -152
  8. package/dist/client.d.ts +104 -35
  9. package/dist/client.js +210 -78
  10. package/dist/compaction.d.ts +103 -60
  11. package/dist/compaction.js +163 -93
  12. package/dist/config.d.ts +48 -12
  13. package/dist/config.js +20 -1
  14. package/dist/continuation.d.ts +25 -17
  15. package/dist/continuation.js +70 -47
  16. package/dist/digest.d.ts +7 -0
  17. package/dist/digest.js +18 -0
  18. package/dist/errors.d.ts +24 -15
  19. package/dist/errors.js +23 -15
  20. package/dist/events.d.ts +108 -58
  21. package/dist/events.js +265 -128
  22. package/dist/guards.d.ts +53 -0
  23. package/dist/guards.js +55 -0
  24. package/dist/hook-events.d.ts +38 -0
  25. package/dist/hook-events.js +36 -0
  26. package/dist/hooks.d.ts +105 -83
  27. package/dist/hooks.js +224 -139
  28. package/dist/index.d.ts +30 -25
  29. package/dist/index.js +29 -23
  30. package/dist/ledger.d.ts +62 -30
  31. package/dist/ledger.js +163 -56
  32. package/dist/platform.d.ts +13 -0
  33. package/dist/platform.js +13 -0
  34. package/dist/preselect.d.ts +222 -0
  35. package/dist/preselect.js +290 -0
  36. package/dist/request-body.d.ts +32 -0
  37. package/dist/request-body.js +47 -0
  38. package/dist/reset.d.ts +1 -0
  39. package/dist/reset.js +7 -6
  40. package/dist/retry.d.ts +40 -143
  41. package/dist/retry.js +79 -285
  42. package/dist/run-calls.d.ts +123 -0
  43. package/dist/run-calls.js +351 -0
  44. package/dist/run-turn.d.ts +44 -16
  45. package/dist/run-turn.js +74 -37
  46. package/dist/runtime.d.ts +23 -17
  47. package/dist/runtime.js +34 -24
  48. package/dist/schema-compat.d.ts +51 -11
  49. package/dist/schema-compat.js +375 -193
  50. package/dist/scope.d.ts +28 -5
  51. package/dist/scope.js +37 -10
  52. package/dist/side-task.d.ts +127 -39
  53. package/dist/side-task.js +189 -100
  54. package/dist/snapshot.d.ts +12 -7
  55. package/dist/snapshot.js +87 -65
  56. package/dist/spec.d.ts +66 -36
  57. package/dist/spec.js +514 -265
  58. package/dist/stream.d.ts +73 -16
  59. package/dist/stream.js +228 -98
  60. package/dist/thinking.d.ts +23 -8
  61. package/dist/thinking.js +96 -46
  62. package/dist/tokens.d.ts +171 -6
  63. package/dist/tokens.js +326 -6
  64. package/dist/tool-calls.d.ts +89 -15
  65. package/dist/tool-calls.js +205 -85
  66. package/dist/tool-loading.d.ts +109 -187
  67. package/dist/tool-loading.js +286 -372
  68. package/dist/wire.d.ts +81 -0
  69. package/dist/wire.js +73 -0
  70. package/llms.txt +87 -43
  71. package/package.json +4 -4
package/dist/spec.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { RESERVED_BODY_FIELDS, ToolDiscovery } from './config.js';
2
+ import { isRecord } from './guards.js';
3
+ import { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hook-events.js';
1
4
  /**
2
5
  * A JSON document that defines an agent, and the rules for reading one.
3
6
  *
@@ -8,47 +11,43 @@
8
11
  * real ceiling of zero* in the third. This is the shape they can all write and all read, argued
9
12
  * out in `docs/agent-spec.md` before any of it was code.
10
13
  *
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`.
14
+ * Nothing else in `src/` imports this module, and this module imports nothing that needs Node:
15
+ * types, and leaves with no imports of their own. That is the same seam `config.ts` draws — the
16
+ * loop takes the parts it reads, not a config object — and it is what lets the document travel
17
+ * between hosts without the loop growing an opinion about where an agent comes from. It is also
18
+ * why this is importable in a browser, where `client.ts` and `hooks.ts` are not: a host validating
19
+ * a pasted document in a form needs no `node:crypto`.
16
20
  */
17
21
  /** The token a document declares itself with. A loader refuses anything else. */
18
- export const AGENT_SPEC = "cubicecho.agent/1";
22
+ export const AGENT_SPEC = 'cubicecho.agent/1';
19
23
  /** The major version `AGENT_SPEC` pins. Raised only when a field changes meaning. */
20
24
  export const AGENT_SPEC_VERSION = 1;
21
25
  /**
22
26
  * The side tasks this package has code for: documentation and a sensible UI order, not a
23
27
  * validator.
24
28
  *
29
+ * @remarks
25
30
  * `tasks` is an open record on purpose — a host may run tasks this package has never heard of,
26
31
  * and an unknown key is carried through untouched rather than warned about.
27
32
  */
28
- export const AGENT_TASKS = ["compaction", "toolSelect", "title", "followups"];
33
+ export const AGENT_TASKS = ['compaction', 'toolSelect', 'title', 'followups'];
29
34
  /**
30
- * Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
35
+ * Every event a hook may be bound to: `HOOK_EVENTS`, under the name a document's reader knows.
31
36
  *
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.
37
+ * @remarks
38
+ * The same array, not a copy. It comes from `hook-events.ts` rather than `hooks.ts` because that
39
+ * module's `createHash` would otherwise come with it, into a file whose whole point is that a
40
+ * browser can load it.
36
41
  */
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"]);
47
- /** Body fields the loop owns. `buildBody` would overwrite them anyway; better to say so on import. */
48
- const RESERVED_BODY = ["model", "messages", "stream", "tools"];
42
+ export const SPEC_EVENTS = HOOK_EVENTS;
43
+ /** The `ref.type` of a prompt part whose text is in a file. */
44
+ export const REF_FILE = 'file';
45
+ /** The `ref.type` of a prompt part whose text is behind a URL. */
46
+ export const REF_URL = 'url';
49
47
  /**
50
48
  * What a field falls back to when no layer said anything at all.
51
49
  *
50
+ * @remarks
52
51
  * Not sentinels, and not invented: `toolDiscovery`, `maxRetries` and `contextLength` are the
53
52
  * "absent everywhere" column of the document's own table, and `temperature` and `maxToolIterations`
54
53
  * are what all three consumers store as their defaults today. A field left out here is left out of
@@ -58,12 +57,16 @@ export const RESOLVED_DEFAULTS = {
58
57
  temperature: 0.7,
59
58
  maxTokens: 0,
60
59
  contextLength: 0,
61
- toolDiscovery: "eager",
60
+ toolDiscovery: ToolDiscovery.Eager,
62
61
  maxToolIterations: 20,
63
62
  maxRetries: 0,
64
63
  };
65
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
66
- /** Absent and null are the same thing: a nullable column round-trips through JSON as `null`. */
64
+ /**
65
+ * Absent and null are the same thing: a nullable column round-trips through JSON as `null`.
66
+ *
67
+ * @param value - Whatever a document held under a key.
68
+ * @returns `true` for `undefined` and `null` only — `0`, `""`, `false` and `[]` are values.
69
+ */
67
70
  const absent = (value) => value === undefined || value === null;
68
71
  /** A `${VAR}`-shaped string, which is kept as the literal it is rather than expanded. */
69
72
  const VARIABLE = /\$\{[^}]*\}/;
@@ -71,112 +74,233 @@ const VARIABLE = /\$\{[^}]*\}/;
71
74
  class Report {
72
75
  errors = [];
73
76
  warnings = [];
77
+ /**
78
+ * Records a reason to refuse the document.
79
+ *
80
+ * @param path - Where in the document the problem is, as `requires[2]`.
81
+ * @param problem - What is wrong there, worded to follow the path and a colon.
82
+ */
74
83
  fail(path, problem) {
75
84
  this.errors.push(`${path}: ${problem}`);
76
85
  }
86
+ /**
87
+ * Records a field that did not survive, as a warning.
88
+ *
89
+ * @param path - Where in the document the field is.
90
+ * @param problem - Why it could not be used, worded to read before ", and was dropped".
91
+ */
77
92
  drop(path, problem) {
78
93
  this.warnings.push(`${path}: ${problem}, and was dropped`);
79
94
  }
95
+ /**
96
+ * Records a warning about something that was kept as it is.
97
+ *
98
+ * @param path - Where in the document it is.
99
+ * @param problem - What is worth an operator's attention there.
100
+ */
80
101
  note(path, problem) {
81
102
  this.warnings.push(`${path}: ${problem}`);
82
103
  }
83
104
  /**
84
- * A string field, or nothing. Interpolation is never performed, so a `${VAR}` is reported as
85
- * the literal it will stay — the alternative hands whoever wrote the document the ability to
86
- * read any variable the process has, which is why the formats that do it need a credential
87
- * denylist bolted on afterwards.
105
+ * A string field, or nothing.
106
+ *
107
+ * @param path - Where the field is, for the warnings.
108
+ * @param value - What the document held there.
109
+ * @returns The string exactly as written, an empty one included. `undefined` where it is absent,
110
+ * and where it is not a string, which is warned about as well.
111
+ *
112
+ * @remarks
113
+ * Interpolation is never performed, so a `${VAR}` is reported as the literal it will stay — the
114
+ * alternative hands whoever wrote the document the ability to read any variable the process has,
115
+ * which is why the formats that do it need a credential denylist bolted on afterwards.
88
116
  */
89
117
  string(path, value) {
90
- if (absent(value))
118
+ if (absent(value)) {
91
119
  return undefined;
92
- if (typeof value !== "string")
93
- return void this.drop(path, "must be a string");
94
- if (VARIABLE.test(value))
95
- this.note(path, "looks like a variable reference, which is never expanded");
120
+ }
121
+ if (typeof value !== 'string') {
122
+ return void this.drop(path, 'must be a string');
123
+ }
124
+ if (VARIABLE.test(value)) {
125
+ this.note(path, 'looks like a variable reference, which is never expanded');
126
+ }
96
127
  return value;
97
128
  }
129
+ /**
130
+ * A number field held to its bounds, or nothing.
131
+ *
132
+ * @param path - Where the field is, for the warning.
133
+ * @param value - What the document held there.
134
+ * @param least - The lowest value accepted, itself included.
135
+ * @param [most] - The highest value accepted, itself included.
136
+ * @returns The number as written. `undefined` where it is absent, and where it is not a finite
137
+ * number or lies outside the bounds — dropped with a warning, never clamped.
138
+ */
98
139
  number(path, value, least, most = Infinity) {
99
- if (absent(value))
140
+ if (absent(value)) {
100
141
  return undefined;
101
- if (typeof value !== "number" || !Number.isFinite(value))
102
- return void this.drop(path, "must be a number");
142
+ }
143
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
144
+ return void this.drop(path, 'must be a number');
145
+ }
103
146
  // Dropped, never clamped: a clamp invents a number the author did not write, while dropping
104
147
  // falls back to one somebody did — the layer below, or nothing.
105
- if (value < least || value > most)
148
+ if (value < least || value > most) {
106
149
  return void this.drop(path, `must be ${most === Infinity ? `at least ${least}` : `between ${least} and ${most}`}`);
150
+ }
107
151
  return value;
108
152
  }
153
+ /**
154
+ * A boolean field, or nothing.
155
+ *
156
+ * @param path - Where the field is, for the warning.
157
+ * @param value - What the document held there.
158
+ * @returns The boolean as written. `undefined` where it is absent, and where it is anything but
159
+ * `true` or `false` — dropped with a warning, never coerced.
160
+ */
109
161
  boolean(path, value) {
110
- if (absent(value))
162
+ if (absent(value)) {
111
163
  return undefined;
112
- if (typeof value !== "boolean")
113
- return void this.drop(path, "must be true or false");
164
+ }
165
+ if (typeof value !== 'boolean') {
166
+ return void this.drop(path, 'must be true or false');
167
+ }
114
168
  return value;
115
169
  }
116
170
  }
117
- /** Drops the keys whose value did not survive, so an absent field stays absent. */
171
+ /**
172
+ * Drops the keys whose value did not survive, so an absent field stays absent.
173
+ *
174
+ * @param fields - Read, not written to. Only `undefined` counts as not surviving: `0`, `""`,
175
+ * `false` and `null` stay.
176
+ * @returns A new object holding the keys that are left.
177
+ */
118
178
  function kept(fields) {
119
- const out = {};
120
- for (const [key, value] of Object.entries(fields))
121
- if (value !== undefined)
122
- out[key] = value;
123
- return out;
179
+ // The one assertion: `Object.entries` forgets which keys it was handed, and `Kept` says them again.
180
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
124
181
  }
125
- /** Whether a parsed sub-object said anything at all. An empty one is the same as an absent one. */
182
+ /**
183
+ * Whether a parsed sub-object said anything at all.
184
+ *
185
+ * @param value - The object to ask about. Only its own keys are counted.
186
+ * @returns `false` for an object with no keys, whatever the ones it has hold.
187
+ *
188
+ * @remarks
189
+ * An empty one is the same as an absent one.
190
+ */
126
191
  const said = (value) => Object.keys(value).length > 0;
192
+ /**
193
+ * Reads an endpoint section, the agent's or a side task's.
194
+ *
195
+ * @param report - Where what is dropped is written.
196
+ * @param path - Where the section sits in the document: `endpoint`, or `tasks.title.endpoint`.
197
+ * @param value - What the document held there.
198
+ * @returns The fields that survived, or `undefined` where the section is absent, is not an object
199
+ * or held nothing usable.
200
+ *
201
+ * @remarks
202
+ * An `apiKey` or an `api_key` is dropped with a warning: a document carries no credentials.
203
+ */
127
204
  function parseEndpoint(report, path, value) {
128
- if (absent(value))
205
+ if (absent(value)) {
129
206
  return undefined;
130
- if (!isRecord(value))
131
- return void report.drop(path, "must be an object");
207
+ }
208
+ if (!isRecord(value)) {
209
+ return void report.drop(path, 'must be an object');
210
+ }
132
211
  const endpoint = kept({
133
212
  baseUrl: report.string(`${path}.baseUrl`, value.baseUrl),
134
213
  requestTimeoutSeconds: report.number(`${path}.requestTimeoutSeconds`, value.requestTimeoutSeconds, 0),
135
214
  firstTokenSeconds: report.number(`${path}.firstTokenSeconds`, value.firstTokenSeconds, 0),
136
215
  });
137
- if (!absent(value.apiKey) || !absent(value.api_key))
138
- report.drop(`${path}.apiKey`, "is not part of this format — a document carries no credentials");
216
+ if (!absent(value.apiKey) || !absent(value.api_key)) {
217
+ report.drop(`${path}.apiKey`, 'is not part of this format — a document carries no credentials');
218
+ }
139
219
  return said(endpoint) ? endpoint : undefined;
140
220
  }
221
+ /** The highest temperature the wire takes. */
222
+ const MAX_TEMPERATURE = 2;
223
+ /**
224
+ * The three fields a model and a side task both carry, held to the same bounds in each.
225
+ *
226
+ * @param report - Where what is dropped is written.
227
+ * @param path - The section the fields sit in: `model`, or `tasks.title`.
228
+ * @param value - That section.
229
+ * @returns All three keys, each `undefined` where its field is absent or was dropped — a caller
230
+ * puts the result through `kept`.
231
+ */
232
+ const parseSampling = (report, path, value) => ({
233
+ maxTokens: report.number(`${path}.maxTokens`, value.maxTokens, 0),
234
+ temperature: report.number(`${path}.temperature`, value.temperature, 0, MAX_TEMPERATURE),
235
+ reasoningEffort: report.string(`${path}.reasoningEffort`, value.reasoningEffort),
236
+ });
237
+ /**
238
+ * Reads the `model` section.
239
+ *
240
+ * @param report - Where what is dropped is written.
241
+ * @param value - What the document held under `model`.
242
+ * @returns The fields that survived — an empty object where none did — or `undefined` where the
243
+ * section is absent or is not an object.
244
+ *
245
+ * @remarks
246
+ * `extraBody` comes back as a new object without the `RESERVED_BODY_FIELDS`, each of which is
247
+ * warned about. An empty one survives as an empty one.
248
+ */
141
249
  function parseModel(report, value) {
142
- if (absent(value))
250
+ if (absent(value)) {
143
251
  return undefined;
144
- if (!isRecord(value))
145
- return void report.drop("model", "must be an object");
252
+ }
253
+ if (!isRecord(value)) {
254
+ return void report.drop('model', 'must be an object');
255
+ }
146
256
  let extraBody;
147
257
  if (!absent(value.extraBody)) {
148
- if (!isRecord(value.extraBody))
149
- report.drop("model.extraBody", "must be an object");
258
+ if (!isRecord(value.extraBody)) {
259
+ report.drop('model.extraBody', 'must be an object');
260
+ }
150
261
  else {
151
262
  extraBody = {};
152
263
  for (const [key, held] of Object.entries(value.extraBody)) {
153
- if (RESERVED_BODY.includes(key))
264
+ // `buildBody` would overwrite them anyway; better to say so on import.
265
+ if (RESERVED_BODY_FIELDS.includes(key)) {
154
266
  report.drop(`model.extraBody.${key}`, "is the loop's to set");
155
- else
267
+ }
268
+ else {
156
269
  extraBody[key] = held;
270
+ }
157
271
  }
158
272
  }
159
273
  }
160
274
  return kept({
161
- 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),
165
- contextLength: report.number("model.contextLength", value.contextLength, 0),
275
+ model: report.string('model.model', value.model),
276
+ ...parseSampling(report, 'model', value),
277
+ contextLength: report.number('model.contextLength', value.contextLength, 0),
166
278
  extraBody,
167
279
  });
168
280
  }
281
+ /**
282
+ * Reads the `prompt` array, part by part.
283
+ *
284
+ * @param report - Where what is dropped is written.
285
+ * @param value - The array, already known to be one.
286
+ * @returns The parts that survived, in the document's order. Empty where none did.
287
+ *
288
+ * @remarks
289
+ * A part goes whole where it is not an object, has no id or an empty one, or has neither a text
290
+ * nor a usable reference. A malformed `ref` beside a text goes alone. An empty text is kept: it is
291
+ * how a layer deletes the part below it.
292
+ */
169
293
  function parsePrompt(report, value) {
170
294
  const parts = [];
171
295
  for (const [index, held] of value.entries()) {
172
296
  const path = `prompt[${index}]`;
173
297
  if (!isRecord(held)) {
174
- report.drop(path, "must be an object");
298
+ report.drop(path, 'must be an object');
175
299
  continue;
176
300
  }
177
301
  const id = report.string(`${path}.id`, held.id);
178
302
  if (!id) {
179
- report.drop(path, "has no id, and merging happens on the id");
303
+ report.drop(path, 'has no id, and merging happens on the id');
180
304
  continue;
181
305
  }
182
306
  const text = report.string(`${path}.text`, held.text);
@@ -185,76 +309,118 @@ function parsePrompt(report, value) {
185
309
  const raw = held.ref;
186
310
  const type = isRecord(raw) ? raw.type : undefined;
187
311
  const value = isRecord(raw) ? report.string(`${path}.ref.value`, raw.value) : undefined;
188
- if ((type === "file" || type === "url") && value)
312
+ if ((type === REF_FILE || type === REF_URL) && value) {
189
313
  ref = { type, value };
190
- else
314
+ }
315
+ else {
191
316
  report.drop(`${path}.ref`, 'must be { type: "file" | "url", value }');
317
+ }
192
318
  }
193
319
  // An empty text is how a layer deletes the part below it, so it is not the same as neither.
194
320
  if (text === undefined && !ref) {
195
- report.drop(path, "has neither text nor a reference");
321
+ report.drop(path, 'has neither text nor a reference');
196
322
  continue;
197
323
  }
198
324
  parts.push(kept({ id, text, ref }));
199
325
  }
200
326
  return parts;
201
327
  }
328
+ /**
329
+ * Reads the `tools` section.
330
+ *
331
+ * @param report - Where what is dropped is written.
332
+ * @param value - What the document held under `tools`.
333
+ * @returns The fields that survived — an empty object where none did — or `undefined` where the
334
+ * section is absent or is not an object.
335
+ *
336
+ * @remarks
337
+ * `servers` keeps its three answers apart: absent stays absent, and an empty array comes back as
338
+ * one. An entry that is empty or not a string is left out, and a list that loses every entry is
339
+ * still a list.
340
+ */
202
341
  function parseTools(report, value) {
203
- if (absent(value))
342
+ if (absent(value)) {
204
343
  return undefined;
205
- if (!isRecord(value))
206
- return void report.drop("tools", "must be an object");
344
+ }
345
+ if (!isRecord(value)) {
346
+ return void report.drop('tools', 'must be an object');
347
+ }
207
348
  let discovery;
208
349
  if (!absent(value.discovery)) {
209
- if (value.discovery === "eager" ||
210
- value.discovery === "ondemand" ||
211
- value.discovery === "proxy")
350
+ if (value.discovery === ToolDiscovery.Eager ||
351
+ value.discovery === ToolDiscovery.OnDemand ||
352
+ value.discovery === ToolDiscovery.Proxy) {
212
353
  discovery = value.discovery;
213
- else
214
- report.drop("tools.discovery", 'must be "eager", "ondemand" or "proxy"');
354
+ }
355
+ else {
356
+ report.drop('tools.discovery', 'must be "eager", "ondemand" or "proxy"');
357
+ }
215
358
  }
216
359
  let servers;
217
360
  if (!absent(value.servers)) {
218
- if (!Array.isArray(value.servers))
219
- report.drop("tools.servers", "must be an array of slugs");
361
+ if (!Array.isArray(value.servers)) {
362
+ report.drop('tools.servers', 'must be an array of slugs');
363
+ }
220
364
  else {
221
365
  // The empty array survives on purpose: it is "no servers", and collapsing it into absent
222
366
  // would turn a scoped agent into one holding every tool on the box.
223
367
  servers = [];
224
368
  for (const [index, slug] of value.servers.entries()) {
225
369
  const name = report.string(`tools.servers[${index}]`, slug);
226
- if (name)
370
+ if (name) {
227
371
  servers.push(name);
228
- else if (name === "")
229
- report.drop(`tools.servers[${index}]`, "is empty");
372
+ }
373
+ else if (name === '') {
374
+ report.drop(`tools.servers[${index}]`, 'is empty');
375
+ }
230
376
  }
231
377
  }
232
378
  }
233
379
  return kept({
234
380
  discovery,
235
- maxIterations: report.number("tools.maxIterations", value.maxIterations, 1),
381
+ maxIterations: report.number('tools.maxIterations', value.maxIterations, 1),
236
382
  servers,
237
383
  });
238
384
  }
385
+ /**
386
+ * Reads the `retry` section.
387
+ *
388
+ * @param report - Where what is dropped is written.
389
+ * @param value - What the document held under `retry`.
390
+ * @returns The fields that survived, or `undefined` where the section is absent, is not an object
391
+ * or held nothing usable.
392
+ */
239
393
  function parseRetry(report, value) {
240
- if (absent(value))
394
+ if (absent(value)) {
241
395
  return undefined;
242
- if (!isRecord(value))
243
- return void report.drop("retry", "must be an object");
396
+ }
397
+ if (!isRecord(value)) {
398
+ return void report.drop('retry', 'must be an object');
399
+ }
244
400
  const retry = kept({
245
- maxRetries: report.number("retry.maxRetries", value.maxRetries, 0),
246
- loadingTimeoutSeconds: report.number("retry.loadingTimeoutSeconds", value.loadingTimeoutSeconds, 0),
401
+ maxRetries: report.number('retry.maxRetries', value.maxRetries, 0),
402
+ loadingTimeoutSeconds: report.number('retry.loadingTimeoutSeconds', value.loadingTimeoutSeconds, 0),
247
403
  });
248
404
  return said(retry) ? retry : undefined;
249
405
  }
406
+ /**
407
+ * Reads the `tasks` record, whatever its keys are.
408
+ *
409
+ * @param report - Where what is dropped is written.
410
+ * @param value - The record, already known to be one.
411
+ * @returns A new record of the tasks that are objects, under the keys the document used. A task
412
+ * that said nothing usable comes back as an empty object; one that is `null` or not an object is
413
+ * left out.
414
+ */
250
415
  function parseTasks(report, value) {
251
416
  const tasks = {};
252
417
  for (const [key, held] of Object.entries(value)) {
253
418
  const path = `tasks.${key}`;
254
- if (absent(held))
419
+ if (absent(held)) {
255
420
  continue;
421
+ }
256
422
  if (!isRecord(held)) {
257
- report.drop(path, "must be an object");
423
+ report.drop(path, 'must be an object');
258
424
  continue;
259
425
  }
260
426
  // An unknown key is not warned about: a host may run side tasks this package has never
@@ -262,19 +428,31 @@ function parseTasks(report, value) {
262
428
  tasks[key] = kept({
263
429
  model: report.string(`${path}.model`, held.model),
264
430
  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),
431
+ ...parseSampling(report, path, held),
268
432
  });
269
433
  }
270
434
  return tasks;
271
435
  }
436
+ /**
437
+ * Reads the `hooks` array, hook by hook.
438
+ *
439
+ * @param report - Where what is dropped or noted is written.
440
+ * @param value - The array, already known to be one.
441
+ * @param events - The events this host fires. A hook bound to any other of `SPEC_EVENTS` is kept
442
+ * and noted.
443
+ * @returns The hooks that survived, in the document's order. Empty where none did.
444
+ *
445
+ * @remarks
446
+ * A hook goes whole where it is not an object, lacks a non-empty `id`, `on`, `server` or `tool`,
447
+ * or is bound to something that is not one of `SPEC_EVENTS`. An `inject: true` or a `veto: true`
448
+ * its event cannot honour goes alone. `args` is carried unread.
449
+ */
272
450
  function parseHooks(report, value, events) {
273
451
  const hooks = [];
274
452
  for (const [index, held] of value.entries()) {
275
453
  const path = `hooks[${index}]`;
276
454
  if (!isRecord(held)) {
277
- report.drop(path, "must be an object");
455
+ report.drop(path, 'must be an object');
278
456
  continue;
279
457
  }
280
458
  const id = report.string(`${path}.id`, held.id);
@@ -282,30 +460,32 @@ function parseHooks(report, value, events) {
282
460
  const tool = report.string(`${path}.tool`, held.tool);
283
461
  const on = report.string(`${path}.on`, held.on);
284
462
  if (!id || !server || !tool || !on) {
285
- report.drop(path, "needs an id, an event, a server and a tool");
463
+ report.drop(path, 'needs an id, an event, a server and a tool');
286
464
  continue;
287
465
  }
288
- if (!SPEC_EVENTS.includes(on)) {
466
+ const event = SPEC_EVENTS.find((known) => known === on);
467
+ if (!event) {
289
468
  report.drop(`${path}.on`, `"${on}" is not an event`);
290
469
  continue;
291
470
  }
292
471
  // A host that never compacts refuses to save a `beforeCompact` hook today, which makes an
293
472
  // otherwise good agent unimportable. Kept and noted instead.
294
- if (!events.includes(on))
473
+ if (!events.includes(on)) {
295
474
  report.note(`${path}.on`, `"${on}" is never fired by this host`);
475
+ }
296
476
  let inject = report.boolean(`${path}.inject`, held.inject);
297
- if (inject && !SPEC_INJECT.has(on)) {
477
+ if (inject && !INJECT_EVENTS.has(event)) {
298
478
  report.drop(`${path}.inject`, `"${on}" runs after the model has already answered`);
299
479
  inject = undefined;
300
480
  }
301
481
  let veto = report.boolean(`${path}.veto`, held.veto);
302
- if (veto && on !== "beforeCompact") {
482
+ if (veto && event !== HookEvent.BeforeCompact) {
303
483
  report.drop(`${path}.veto`, `"${on}" announces nothing a hook can decline`);
304
484
  veto = undefined;
305
485
  }
306
486
  hooks.push(kept({
307
487
  id,
308
- on: on,
488
+ on: event,
309
489
  server,
310
490
  tool,
311
491
  args: absent(held.args) ? undefined : held.args,
@@ -318,28 +498,45 @@ function parseHooks(report, value, events) {
318
498
  }
319
499
  return hooks;
320
500
  }
501
+ /**
502
+ * Reads the `bundle` section, which `parseSpec` hands over only once a host has opted in.
503
+ *
504
+ * @param report - Where what is dropped is written.
505
+ * @param value - What the document held under `bundle`.
506
+ * @returns The servers that survived, possibly none, or `undefined` where the section or its
507
+ * `mcpServers` is absent or of the wrong kind. Each server is the object the document held, not a
508
+ * copy, and no other key of `bundle` is carried.
509
+ *
510
+ * @remarks
511
+ * A server goes where it is not an object, has neither a non-empty `slug` nor a non-empty `id`, or
512
+ * repeats the effective slug of one before it. Nothing else about it is read.
513
+ */
321
514
  function parseBundle(report, value) {
322
- if (absent(value))
515
+ if (absent(value)) {
323
516
  return undefined;
324
- if (!isRecord(value))
325
- return void report.drop("bundle", "must be an object");
326
- if (absent(value.mcpServers))
517
+ }
518
+ if (!isRecord(value)) {
519
+ return void report.drop('bundle', 'must be an object');
520
+ }
521
+ if (absent(value.mcpServers)) {
327
522
  return undefined;
328
- if (!Array.isArray(value.mcpServers))
329
- return void report.drop("bundle.mcpServers", "must be an array");
523
+ }
524
+ if (!Array.isArray(value.mcpServers)) {
525
+ return void report.drop('bundle.mcpServers', 'must be an array');
526
+ }
330
527
  const seen = new Set();
331
528
  const servers = [];
332
529
  for (const [index, held] of value.mcpServers.entries()) {
333
530
  const path = `bundle.mcpServers[${index}]`;
334
531
  if (!isRecord(held)) {
335
- report.drop(path, "must be an object");
532
+ report.drop(path, 'must be an object');
336
533
  continue;
337
534
  }
338
- const slug = typeof held.slug === "string" && held.slug ? held.slug : undefined;
339
- const id = typeof held.id === "string" && held.id ? held.id : undefined;
535
+ const slug = typeof held.slug === 'string' && held.slug ? held.slug : undefined;
536
+ const id = typeof held.id === 'string' && held.id ? held.id : undefined;
340
537
  const effective = slug ?? id;
341
538
  if (!effective) {
342
- report.drop(path, "needs an id or a slug");
539
+ report.drop(path, 'needs an id or a slug');
343
540
  continue;
344
541
  }
345
542
  if (seen.has(effective)) {
@@ -354,25 +551,33 @@ function parseBundle(report, value) {
354
551
  }
355
552
  /** The keys this version names. Anything else is a later 1.x, and is carried rather than erased. */
356
553
  const KNOWN = new Set([
357
- "spec",
358
- "$schema",
359
- "id",
360
- "name",
361
- "description",
362
- "endpoint",
363
- "model",
364
- "prompt",
365
- "tools",
366
- "retry",
367
- "tasks",
368
- "hooks",
369
- "bundle",
370
- "extensions",
371
- "requires",
554
+ 'spec',
555
+ '$schema',
556
+ 'id',
557
+ 'name',
558
+ 'description',
559
+ 'endpoint',
560
+ 'model',
561
+ 'prompt',
562
+ 'tools',
563
+ 'retry',
564
+ 'tasks',
565
+ 'hooks',
566
+ 'bundle',
567
+ 'extensions',
568
+ 'requires',
372
569
  ]);
373
570
  /**
374
571
  * Reads a document, dropping what it cannot use and refusing only what it cannot identify.
375
572
  *
573
+ * @param document - Anything at all — this is the front door, and it is given parsed JSON from a
574
+ * form, a file or another host.
575
+ * @param [options] - Whether to parse a bundle, which events this host fires, and which extension
576
+ * keys it understands.
577
+ * @returns The document that survived — a new object, never the one handed in — or `spec: null`
578
+ * where `errors` holds anything. `warnings` is filled either way.
579
+ *
580
+ * @remarks
376
581
  * Every problem is reported at once, in the `path: what is wrong` shape `validateHooks` already
377
582
  * uses, because a host showing an operator one error at a time makes them fix a document one
378
583
  * round trip at a time.
@@ -388,72 +593,75 @@ const KNOWN = new Set([
388
593
  * version and returns false: degrade where the loss is performance, refuse where the loss is
389
594
  * identity. A snapshot of the wrong version costs a few refused requests; an agent of the wrong
390
595
  * version is not the agent its author described.
391
- *
392
- * @param document Anything at all — this is the front door, and it is given parsed JSON from a
393
- * form, a file or another host.
394
- * @param options Whether to parse a bundle, which events this host fires, and which extension
395
- * keys it understands.
396
596
  */
397
597
  export function parseSpec(document, options = {}) {
398
598
  const report = new Report();
399
599
  const events = options.events ?? SPEC_EVENTS;
400
600
  if (!isRecord(document)) {
401
- report.fail("spec", "must be an object");
601
+ report.fail('spec', 'must be an object');
402
602
  return { spec: null, errors: report.errors, warnings: report.warnings };
403
603
  }
404
604
  const token = document.spec;
405
- if (typeof token !== "string")
406
- report.fail("spec", `must be "${AGENT_SPEC}"`);
605
+ if (typeof token !== 'string') {
606
+ report.fail('spec', `must be "${AGENT_SPEC}"`);
607
+ }
407
608
  else if (token !== AGENT_SPEC) {
408
- const major = Number(token.split("/")[1]);
409
- const named = token.startsWith("cubicecho.agent/") && Number.isInteger(major);
410
- report.fail("spec", named
411
- ? `is version ${major}, and this build reads version ${AGENT_SPEC_VERSION}`
412
- : `must be "${AGENT_SPEC}"`);
609
+ const major = Number(token.split('/')[1]);
610
+ const named = token.startsWith('cubicecho.agent/') && Number.isInteger(major);
611
+ report.fail('spec', named ? `is version ${major}, and this build reads version ${AGENT_SPEC_VERSION}` : `must be "${AGENT_SPEC}"`);
413
612
  }
414
613
  // Containers are checked before their contents: "prompt must be an array" is a different
415
614
  // mistake from "this prompt part has no id", and only the first is worth refusing over.
416
- if (!absent(document.prompt) && !Array.isArray(document.prompt))
417
- report.fail("prompt", "must be an array");
418
- if (!absent(document.tasks) && !isRecord(document.tasks))
419
- report.fail("tasks", "must be an object");
420
- if (!absent(document.hooks) && !Array.isArray(document.hooks))
421
- report.fail("hooks", "must be an array");
615
+ if (!absent(document.prompt) && !Array.isArray(document.prompt)) {
616
+ report.fail('prompt', 'must be an array');
617
+ }
618
+ if (!absent(document.tasks) && !isRecord(document.tasks)) {
619
+ report.fail('tasks', 'must be an object');
620
+ }
621
+ if (!absent(document.hooks) && !Array.isArray(document.hooks)) {
622
+ report.fail('hooks', 'must be an array');
623
+ }
422
624
  const requires = [];
423
625
  if (!absent(document.requires)) {
424
- if (!Array.isArray(document.requires))
425
- report.fail("requires", "must be an array");
426
- else
626
+ if (!Array.isArray(document.requires)) {
627
+ report.fail('requires', 'must be an array');
628
+ }
629
+ else {
427
630
  for (const [index, key] of document.requires.entries()) {
428
- if (typeof key !== "string") {
429
- report.fail(`requires[${index}]`, "must be a string");
631
+ if (typeof key !== 'string') {
632
+ report.fail(`requires[${index}]`, 'must be a string');
430
633
  continue;
431
634
  }
432
635
  // Fails closed: an agent that must not run without its sandbox extension can say so.
433
- if (options.understands && !options.understands.includes(key))
636
+ if (options.understands && !options.understands.includes(key)) {
434
637
  report.fail(`requires[${index}]`, `"${key}" is not understood by this host`);
638
+ }
435
639
  requires.push(key);
436
640
  }
641
+ }
437
642
  }
438
643
  let extensions;
439
644
  if (!absent(document.extensions)) {
440
- if (!isRecord(document.extensions))
441
- report.drop("extensions", "must be an object");
645
+ if (!isRecord(document.extensions)) {
646
+ report.drop('extensions', 'must be an object');
647
+ }
442
648
  // Never validated beyond being an object. It is the escape hatch, and the one place a host
443
649
  // can put what this package has no name for without being told about it.
444
- else
650
+ else {
445
651
  extensions = document.extensions;
652
+ }
653
+ }
654
+ if (!absent(document.bundle) && !options.bundle) {
655
+ report.drop('bundle', 'carries a command line, so it is only read with { bundle: true }');
446
656
  }
447
- if (!absent(document.bundle) && !options.bundle)
448
- report.drop("bundle", "carries a command line, so it is only read with { bundle: true }");
449
657
  const spec = {
450
658
  spec: AGENT_SPEC,
451
659
  ...kept({
452
- $schema: report.string("$schema", document.$schema),
453
- id: report.string("id", document.id),
454
- name: report.string("name", document.name),
455
- description: report.string("description", document.description),
456
- endpoint: parseEndpoint(report, "endpoint", document.endpoint),
660
+ $schema: report.string('$schema', document.$schema),
661
+ id: report.string('id', document.id),
662
+ name: report.string('name', document.name),
663
+ description: report.string('description', document.description),
664
+ endpoint: parseEndpoint(report, 'endpoint', document.endpoint),
457
665
  model: parseModel(report, document.model),
458
666
  prompt: Array.isArray(document.prompt) ? parsePrompt(report, document.prompt) : undefined,
459
667
  tools: parseTools(report, document.tools),
@@ -466,11 +674,12 @@ export function parseSpec(document, options = {}) {
466
674
  }),
467
675
  };
468
676
  for (const [key, held] of Object.entries(document)) {
469
- if (KNOWN.has(key))
677
+ if (KNOWN.has(key)) {
470
678
  continue;
679
+ }
471
680
  // Kept, not erased: a host stores what it was given and resolves on read, so a key from a
472
681
  // later 1.x survives an edit-and-export cycle instead of dying at the first host to open it.
473
- report.note(key, "is not a key this version names, and was carried through unread");
682
+ report.note(key, 'is not a key this version names, and was carried through unread');
474
683
  spec[key] = held;
475
684
  }
476
685
  return {
@@ -479,136 +688,172 @@ export function parseSpec(document, options = {}) {
479
688
  warnings: report.warnings,
480
689
  };
481
690
  }
482
- /** 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
- }
497
- /** An `Endpoint` as the loop takes one. `apiKey` is always empty; see `resolveAgentSpec`. */
691
+ /**
692
+ * Merges the layers that named a key, last one winning, and leaves it absent if none did.
693
+ *
694
+ * @param layers - One section from each document, weakest first. A document without the section
695
+ * gives `undefined`, which is skipped, and a key holding `undefined` overrides nothing.
696
+ * @returns A new object, one level deep — empty where no layer said anything.
697
+ */
698
+ const mergeLayers = (layers) => Object.assign({}, ...layers.map((layer) => kept(layer ?? {})));
699
+ /**
700
+ * An `Endpoint` as the loop takes one.
701
+ *
702
+ * @param spec - The merged endpoint section. Read, not written to.
703
+ * @returns A new object: `baseUrl` is `""` where no layer gave one, and a timeout no layer stated
704
+ * stays absent.
705
+ *
706
+ * @remarks
707
+ * `apiKey` is always empty; see `resolveAgentSpec`.
708
+ */
498
709
  const asEndpoint = (spec) => ({
499
- baseUrl: spec.baseUrl ?? "",
500
- apiKey: "",
710
+ baseUrl: spec.baseUrl ?? '',
711
+ apiKey: '',
501
712
  ...kept({
502
713
  requestTimeoutSeconds: spec.requestTimeoutSeconds,
503
714
  firstTokenSeconds: spec.firstTokenSeconds,
504
715
  }),
505
716
  });
506
717
  /**
507
- * Layers documents into the flat object the loop takes, weakest first.
508
- *
509
- * Absent inherits, and nothing else does: `0`, `-1`, `""` and `[]` are values that mean what they
510
- * say, which is the whole reason the format exists — `maxTokens: 0` means three different things
511
- * across the three applications this was extracted from, and `temperature: 0` is unsayable in one
512
- * of them. Scalars take the last layer that has the key. `extraBody` and `extensions` merge by
513
- * top-level key. `prompt`, `hooks` and `tasks` merge by id, ordered by first appearance, which is
514
- * one rule expressing two behaviours that look unrelated: different ids stack, and the same id
515
- * replaces. `tools.servers` replaces whole, because a tri-state cannot be unioned. `requires`
516
- * unions, so a layer above cannot quietly drop a requirement below it.
718
+ * The layers' `model` sections as one, with their `extraBody` merged by top-level key beside it.
517
719
  *
518
- * No layer is ever fetched. There is no `extends` field and no URL resolution; which documents
519
- * layer is the host's call, the same answer `resolveApiKey` gives about keys. `apiKey` comes back
520
- * empty for the same reason: a document naming somebody else's endpoint must not be able to make
521
- * this host send its own provider key there. A host that wants inheritance applies `resolveApiKey`
522
- * afterwards.
523
- *
524
- * @param layers The documents, weakest first: settings, then the agent, then a task, then a step.
720
+ * @param layers - The documents, weakest first. None is written to.
721
+ * @returns `model`, holding the last stated value of every field but `extraBody`, and `extraBody`
722
+ * apart from it — empty where no layer had one.
525
723
  */
526
- export function resolveAgentSpec(layers) {
527
- const endpoint = mergeEndpoints(layers.map((layer) => layer.endpoint));
724
+ function mergeModel(layers) {
528
725
  const model = {};
529
726
  const extraBody = {};
530
727
  for (const layer of layers) {
531
728
  for (const [key, value] of Object.entries(layer.model ?? {})) {
532
- if (value === undefined)
729
+ if (value === undefined) {
533
730
  continue;
534
- if (key === "extraBody")
731
+ }
732
+ if (key === 'extraBody') {
535
733
  Object.assign(extraBody, value);
536
- else
734
+ }
735
+ else {
537
736
  model[key] = value;
737
+ }
538
738
  }
539
739
  }
540
- const parts = new Map();
541
- for (const layer of layers)
542
- for (const part of layer.prompt ?? [])
543
- 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;
740
+ return { model, extraBody };
741
+ }
742
+ /**
743
+ * Items merged by id, ordered by first appearance: different ids stack, and the same id is laid
744
+ * over the one before it.
745
+ *
746
+ * @param items - Every layer's items in one list, weakest first. None is written to.
747
+ * @param idOf - What an item merges on. `""` is an id like any other.
748
+ * @returns A new array of new objects, each the shallow merge of the items that shared its id.
749
+ *
750
+ * @remarks
751
+ * An item `idOf` gives no id for is left out.
752
+ */
753
+ function mergeById(items, idOf) {
754
+ const merged = new Map();
755
+ for (const item of items) {
756
+ const id = idOf(item);
757
+ if (id !== undefined) {
758
+ merged.set(id, { ...merged.get(id), ...item });
759
+ }
561
760
  }
761
+ return [...merged.values()];
762
+ }
763
+ /**
764
+ * The layers' side tasks as the loop takes them, each on the agent's endpoint with its own laid
765
+ * over it.
766
+ *
767
+ * @param layers - The documents, weakest first. None is written to.
768
+ * @param endpoint - The agent's merged endpoint. Read, not written to.
769
+ * @returns The tasks whose merged `model` is not empty, keyed as the documents keyed them — one a
770
+ * layer declined with `model: ""` is not among them.
771
+ */
772
+ function resolveTasks(layers, endpoint) {
562
773
  const merged = new Map();
563
- for (const layer of layers)
564
- for (const [key, task] of Object.entries(layer.tasks ?? {}))
774
+ for (const layer of layers) {
775
+ for (const [key, task] of Object.entries(layer.tasks ?? {})) {
565
776
  merged.set(key, {
566
777
  ...(merged.get(key) ?? {}),
567
778
  ...task,
568
- endpoint: mergeEndpoints([merged.get(key)?.endpoint, task.endpoint]),
779
+ endpoint: mergeLayers([merged.get(key)?.endpoint, task.endpoint]),
569
780
  });
781
+ }
782
+ }
570
783
  const tasks = {};
571
784
  for (const [key, task] of merged) {
572
785
  // `model: ""` is how a layer declines a task a layer below it configured, so the entry goes
573
786
  // rather than coming back with an empty model for the host to interpret.
574
- if (!task.model)
787
+ if (!task.model) {
575
788
  continue;
789
+ }
576
790
  tasks[key] = kept({
577
791
  model: task.model,
578
- endpoint: asEndpoint(mergeEndpoints([endpoint, task.endpoint])),
792
+ endpoint: asEndpoint(mergeLayers([endpoint, task.endpoint])),
579
793
  maxTokens: task.maxTokens,
580
794
  temperature: task.temperature,
581
795
  reasoningEffort: task.reasoningEffort,
582
796
  });
583
797
  }
584
- const hooks = new Map();
585
- for (const layer of layers)
586
- for (const hook of layer.hooks ?? [])
587
- hooks.set(hook.id, { ...(hooks.get(hook.id) ?? hook), ...hook });
588
- const servers = new Map();
589
- for (const layer of layers)
590
- for (const server of layer.bundle?.mcpServers ?? []) {
591
- const slug = server.slug ?? server.id;
592
- if (slug)
593
- servers.set(slug, { ...(servers.get(slug) ?? {}), ...server });
594
- }
798
+ return tasks;
799
+ }
800
+ /**
801
+ * Layers documents into the flat object the loop takes, weakest first.
802
+ *
803
+ * @param layers - The documents, weakest first: settings, then the agent, then a task, then a step.
804
+ * @returns The resolved agent, built new. Always whole: an empty list gives empty strings and
805
+ * `RESOLVED_DEFAULTS` rather than an error, and `apiKey` is `""` whatever the layers said.
806
+ *
807
+ * @remarks
808
+ * Absent inherits, and nothing else does: `0`, `-1`, `""` and `[]` are values that mean what they
809
+ * say, which is the whole reason the format exists — `maxTokens: 0` means three different things
810
+ * across the three applications this was extracted from, and `temperature: 0` is unsayable in one
811
+ * of them. Scalars take the last layer that has the key. `extraBody` and `extensions` merge by
812
+ * top-level key. `prompt`, `hooks` and `tasks` merge by id, ordered by first appearance, which is
813
+ * one rule expressing two behaviours that look unrelated: different ids stack, and the same id
814
+ * replaces. `tools.servers` replaces whole, because a tri-state cannot be unioned. `requires`
815
+ * unions, so a layer above cannot quietly drop a requirement below it.
816
+ *
817
+ * No layer is ever fetched. There is no `extends` field and no URL resolution; which documents
818
+ * layer is the host's call, the same answer `resolveApiKey` gives about keys. `apiKey` comes back
819
+ * empty for the same reason: a document naming somebody else's endpoint must not be able to make
820
+ * this host send its own provider key there. A host that wants inheritance applies `resolveApiKey`
821
+ * afterwards.
822
+ */
823
+ export function resolveAgentSpec(layers) {
824
+ const endpoint = mergeLayers(layers.map((layer) => layer.endpoint));
825
+ const { model, extraBody } = mergeModel(layers);
826
+ // `servers` is replaced whole rather than unioned: a list, an empty list and no list are three
827
+ // answers, and a union of the first two is the first, which loses the scoping the operator
828
+ // asked for. Copied, so the resolved agent does not share an array with the layer it came from.
829
+ const tools = mergeLayers(layers.map((layer) => layer.tools));
830
+ if (tools.servers) {
831
+ tools.servers = [...tools.servers];
832
+ }
833
+ const retry = mergeLayers(layers.map((layer) => layer.retry));
834
+ const tasks = resolveTasks(layers, endpoint);
835
+ const prompt = mergeById(layers.flatMap((layer) => layer.prompt ?? []), (part) => part.id);
836
+ const hooks = mergeById(layers.flatMap((layer) => layer.hooks ?? []), (hook) => hook.id);
837
+ const servers = mergeById(layers.flatMap((layer) => layer.bundle?.mcpServers ?? []), (server) => (server.slug ?? server.id) || undefined);
595
838
  const extensions = {};
596
- for (const layer of layers)
839
+ for (const layer of layers) {
597
840
  Object.assign(extensions, layer.extensions ?? {});
841
+ }
598
842
  const requires = new Set();
599
- for (const layer of layers)
600
- for (const key of layer.requires ?? [])
843
+ for (const layer of layers) {
844
+ for (const key of layer.requires ?? []) {
601
845
  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;
605
- const prompt = [...parts.values()];
846
+ }
847
+ }
848
+ const id = layers.findLast((layer) => layer.id)?.id ?? '';
849
+ const name = layers.findLast((layer) => layer.name)?.name;
850
+ const description = layers.findLast((layer) => layer.description)?.description;
606
851
  return {
607
852
  id,
608
853
  name: name ?? id,
609
- description: description ?? "",
610
- baseUrl: endpoint.baseUrl ?? "",
611
- apiKey: "",
854
+ description: description ?? '',
855
+ baseUrl: endpoint.baseUrl ?? '',
856
+ apiKey: '',
612
857
  ...kept({
613
858
  requestTimeoutSeconds: endpoint.requestTimeoutSeconds,
614
859
  firstTokenSeconds: endpoint.firstTokenSeconds,
@@ -617,24 +862,24 @@ export function resolveAgentSpec(layers) {
617
862
  extraBody: said(extraBody) ? extraBody : undefined,
618
863
  servers: tools.servers,
619
864
  }),
620
- model: model.model ?? "",
865
+ model: model.model ?? '',
621
866
  maxTokens: model.maxTokens ?? RESOLVED_DEFAULTS.maxTokens,
622
867
  temperature: model.temperature ?? RESOLVED_DEFAULTS.temperature,
623
868
  contextLength: model.contextLength ?? RESOLVED_DEFAULTS.contextLength,
624
869
  toolDiscovery: tools.discovery ?? RESOLVED_DEFAULTS.toolDiscovery,
625
- toolSelectModel: tasks.toolSelect?.model ?? "",
870
+ toolSelectModel: tasks.toolSelect?.model ?? '',
626
871
  maxToolIterations: tools.maxIterations ?? RESOLVED_DEFAULTS.maxToolIterations,
627
872
  maxRetries: retry.maxRetries ?? RESOLVED_DEFAULTS.maxRetries,
628
873
  // Byte-identical to what a consumer's `systemPromptFor` produces today, so four sources
629
874
  // survive a round trip instead of flattening into one blob nobody can edit a third of.
630
875
  systemPrompt: prompt
631
- .map((part) => part.text?.trim() ?? "")
876
+ .map((part) => part.text?.trim() ?? '')
632
877
  .filter(Boolean)
633
- .join("\n\n"),
878
+ .join('\n\n'),
634
879
  prompt,
635
880
  tasks,
636
- hooks: [...hooks.values()],
637
- mcpServers: [...servers.values()],
881
+ hooks,
882
+ mcpServers: servers,
638
883
  extensions,
639
884
  requires: [...requires],
640
885
  };
@@ -642,18 +887,22 @@ export function resolveAgentSpec(layers) {
642
887
  /**
643
888
  * A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
644
889
  *
890
+ * @param spec - A document, ordinarily one `parseSpec` returned.
891
+ * @param [options] - Whether to keep the secrets.
892
+ * @returns A copy one level deep, never `spec` itself: what is not redacted is shared with the
893
+ * original.
894
+ *
895
+ * @remarks
645
896
  * The property worth keeping is that **a redacted spec is still a valid spec** — it parses, it
646
897
  * resolves, and what comes back is the same agent pointed at the same servers, needing only the
647
- * credentials the importing host supplies itself. So this removes values rather than keys, and
648
- * nothing outside `bundle` is touched: the format has no credential field at any depth, which is
649
- * the reason there is nothing else to strip.
650
- *
651
- * @param spec A document, ordinarily one `parseSpec` returned.
652
- * @param options Whether to keep the secrets. The default is not to.
898
+ * credentials the importing host supplies itself. So a bundled server loses its `env` and its
899
+ * `headers` and nothing else, and nothing outside `bundle` is touched: the format has no
900
+ * credential field at any depth, which is the reason there is nothing else to strip.
653
901
  */
654
902
  export function exportSpec(spec, { secrets = false } = {}) {
655
- if (secrets || !spec.bundle?.mcpServers)
903
+ if (secrets || !spec.bundle?.mcpServers) {
656
904
  return { ...spec };
905
+ }
657
906
  return {
658
907
  ...spec,
659
908
  bundle: {