@cubicecho/agent-core 2.24.0 → 2.25.1
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.
- package/README.md +10 -7
- package/dist/agent-loop.d.ts +84 -205
- package/dist/agent-loop.js +288 -597
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -23
- package/dist/capabilities.d.ts +111 -42
- package/dist/capabilities.js +281 -138
- package/dist/client.d.ts +104 -35
- package/dist/client.js +205 -74
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +160 -86
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +24 -16
- package/dist/continuation.js +70 -44
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +100 -63
- package/dist/events.js +250 -120
- package/dist/guards.d.ts +49 -4
- package/dist/guards.js +60 -5
- package/dist/hook-events.d.ts +21 -5
- package/dist/hook-events.js +23 -9
- package/dist/hooks.d.ts +96 -73
- package/dist/hooks.js +202 -116
- package/dist/index.d.ts +30 -26
- package/dist/index.js +29 -24
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +15 -0
- package/dist/platform.js +15 -0
- package/dist/preselect.d.ts +223 -0
- package/dist/preselect.js +291 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +44 -138
- package/dist/retry.js +83 -281
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +36 -15
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +35 -18
- package/dist/schema-compat.js +346 -176
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +124 -40
- package/dist/side-task.js +186 -66
- package/dist/snapshot.d.ts +10 -5
- package/dist/snapshot.js +73 -30
- package/dist/spec.d.ts +56 -25
- package/dist/spec.js +494 -215
- package/dist/stream.d.ts +66 -16
- package/dist/stream.js +220 -97
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +92 -44
- package/dist/tokens.d.ts +164 -6
- package/dist/tokens.js +319 -6
- package/dist/tool-calls.d.ts +90 -17
- package/dist/tool-calls.js +207 -84
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +257 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
package/dist/spec.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 =
|
|
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 = [
|
|
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
|
-
/**
|
|
41
|
-
const
|
|
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:
|
|
60
|
+
toolDiscovery: ToolDiscovery.Eager,
|
|
55
61
|
maxToolIterations: 20,
|
|
56
62
|
maxRetries: 0,
|
|
57
63
|
};
|
|
58
|
-
/**
|
|
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.
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
123
|
-
|
|
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`,
|
|
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
|
|
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,
|
|
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
|
-
|
|
143
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
154
269
|
extraBody[key] = held;
|
|
270
|
+
}
|
|
155
271
|
}
|
|
156
272
|
}
|
|
157
273
|
}
|
|
158
274
|
return kept({
|
|
159
|
-
model: report.string(
|
|
160
|
-
...parseSampling(report,
|
|
161
|
-
contextLength: report.number(
|
|
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,
|
|
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,
|
|
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 ===
|
|
312
|
+
if ((type === REF_FILE || type === REF_URL) && value) {
|
|
185
313
|
ref = { type, value };
|
|
186
|
-
|
|
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,
|
|
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
|
-
|
|
202
|
-
|
|
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 ===
|
|
206
|
-
value.discovery ===
|
|
207
|
-
value.discovery ===
|
|
350
|
+
if (value.discovery === ToolDiscovery.Eager ||
|
|
351
|
+
value.discovery === ToolDiscovery.OnDemand ||
|
|
352
|
+
value.discovery === ToolDiscovery.Proxy) {
|
|
208
353
|
discovery = value.discovery;
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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
|
-
|
|
225
|
-
|
|
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(
|
|
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
|
-
|
|
239
|
-
|
|
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(
|
|
242
|
-
loadingTimeoutSeconds: report.number(
|
|
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,
|
|
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,
|
|
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,
|
|
463
|
+
report.drop(path, 'needs an id, an event, a server and a tool');
|
|
280
464
|
continue;
|
|
281
465
|
}
|
|
282
|
-
|
|
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(
|
|
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 &&
|
|
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:
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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,
|
|
532
|
+
report.drop(path, 'must be an object');
|
|
330
533
|
continue;
|
|
331
534
|
}
|
|
332
|
-
const slug = typeof held.slug ===
|
|
333
|
-
const id = typeof held.id ===
|
|
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,
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
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 !==
|
|
400
|
-
report.fail(
|
|
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(
|
|
403
|
-
const named = token.startsWith(
|
|
404
|
-
report.fail(
|
|
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(
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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(
|
|
420
|
-
|
|
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 !==
|
|
423
|
-
report.fail(`requires[${index}]`,
|
|
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(
|
|
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(
|
|
447
|
-
id: report.string(
|
|
448
|
-
name: report.string(
|
|
449
|
-
description: report.string(
|
|
450
|
-
endpoint: parseEndpoint(report,
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
731
|
+
}
|
|
732
|
+
if (key === 'extraBody') {
|
|
516
733
|
Object.assign(extraBody, value);
|
|
517
|
-
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
518
736
|
model[key] = value;
|
|
737
|
+
}
|
|
519
738
|
}
|
|
520
739
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
534
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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(
|
|
878
|
+
.join('\n\n'),
|
|
604
879
|
prompt,
|
|
605
880
|
tasks,
|
|
606
|
-
hooks
|
|
607
|
-
mcpServers:
|
|
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
|
|
618
|
-
* nothing outside `bundle` is touched: the format has no
|
|
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: {
|