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