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