@cubicecho/agent-core 2.16.0 → 2.18.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 +80 -1
- package/dist/agent-loop.d.ts +15 -4
- package/dist/agent-loop.js +19 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/spec.d.ts +319 -0
- package/dist/spec.js +662 -0
- package/dist/tool-loading.d.ts +73 -0
- package/dist/tool-loading.js +125 -0
- package/llms.txt +34 -0
- package/package.json +5 -1
package/dist/spec.js
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSON document that defines an agent, and the rules for reading one.
|
|
3
|
+
*
|
|
4
|
+
* Four applications sit on this package and each one spells "an agent" differently: a singleton
|
|
5
|
+
* settings row, a deliberately jobless agent table whose job comes from three other rows, an
|
|
6
|
+
* override profile that resolves into a whole synthesised settings object. None of them can read
|
|
7
|
+
* another's agents, and `maxTokens: 0` means *no ceiling* in one, *inherit* in the next and *a
|
|
8
|
+
* real ceiling of zero* in the third. This is the shape they can all write and all read, argued
|
|
9
|
+
* out in `docs/agent-spec.md` before any of it was code.
|
|
10
|
+
*
|
|
11
|
+
* Nothing else in `src/` imports this module, and this module imports nothing but types. That is
|
|
12
|
+
* the same seam `config.ts` draws — the loop takes the parts it reads, not a config object — and
|
|
13
|
+
* it is what lets the document travel between hosts without the loop growing an opinion about
|
|
14
|
+
* where an agent comes from. It is also why this is importable in a browser, where `client.ts`
|
|
15
|
+
* and `hooks.ts` are not: a host validating a pasted document in a form needs no `node:crypto`.
|
|
16
|
+
*/
|
|
17
|
+
/** The token a document declares itself with. A loader refuses anything else. */
|
|
18
|
+
export const AGENT_SPEC = "cubicecho.agent/1";
|
|
19
|
+
/** The major version `AGENT_SPEC` pins. Raised only when a field changes meaning. */
|
|
20
|
+
export const AGENT_SPEC_VERSION = 1;
|
|
21
|
+
/**
|
|
22
|
+
* The side tasks this package has code for: documentation and a sensible UI order, not a
|
|
23
|
+
* validator.
|
|
24
|
+
*
|
|
25
|
+
* `tasks` is an open record on purpose — a host may run tasks this package has never heard of,
|
|
26
|
+
* and an unknown key is carried through untouched rather than warned about.
|
|
27
|
+
*/
|
|
28
|
+
export const AGENT_TASKS = ["compaction", "toolSelect", "title", "followups"];
|
|
29
|
+
/**
|
|
30
|
+
* Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
|
|
31
|
+
*
|
|
32
|
+
* `hooks.ts` owns the list; importing its `HOOK_EVENTS` would pull the whole module, and its
|
|
33
|
+
* `createHash` with it, into a file whose whole point is that a browser can load it. The `HookEvent`
|
|
34
|
+
* type is imported and erased, so a removed event fails to compile here, and a test asserts this
|
|
35
|
+
* list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
|
|
36
|
+
*/
|
|
37
|
+
export const SPEC_EVENTS = [
|
|
38
|
+
"sessionStart",
|
|
39
|
+
"beforeTurn",
|
|
40
|
+
"afterTurn",
|
|
41
|
+
"beforeCompact",
|
|
42
|
+
"sessionEnd",
|
|
43
|
+
"sessionDelete",
|
|
44
|
+
];
|
|
45
|
+
/** The events whose output can still reach a request, so the only ones `inject` means anything on. */
|
|
46
|
+
const SPEC_INJECT = new Set(["sessionStart", "beforeTurn"]);
|
|
47
|
+
/** Body fields the loop owns. `buildBody` would overwrite them anyway; better to say so on import. */
|
|
48
|
+
const RESERVED_BODY = ["model", "messages", "stream", "tools"];
|
|
49
|
+
/**
|
|
50
|
+
* What a field falls back to when no layer said anything at all.
|
|
51
|
+
*
|
|
52
|
+
* Not sentinels, and not invented: `toolDiscovery`, `maxRetries` and `contextLength` are the
|
|
53
|
+
* "absent everywhere" column of the document's own table, and `temperature` and `maxToolIterations`
|
|
54
|
+
* are what all three consumers store as their defaults today. A field left out here is left out of
|
|
55
|
+
* the resolved object too, which is how `requestTimeoutSeconds` keeps meaning "no opinion".
|
|
56
|
+
*/
|
|
57
|
+
export const RESOLVED_DEFAULTS = {
|
|
58
|
+
temperature: 0.7,
|
|
59
|
+
maxTokens: 0,
|
|
60
|
+
contextLength: 0,
|
|
61
|
+
toolDiscovery: "eager",
|
|
62
|
+
maxToolIterations: 20,
|
|
63
|
+
maxRetries: 0,
|
|
64
|
+
};
|
|
65
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
/** Absent and null are the same thing: a nullable column round-trips through JSON as `null`. */
|
|
67
|
+
const absent = (value) => value === undefined || value === null;
|
|
68
|
+
/** A `${VAR}`-shaped string, which is kept as the literal it is rather than expanded. */
|
|
69
|
+
const VARIABLE = /\$\{[^}]*\}/;
|
|
70
|
+
/** Problems in the order they were found, in `validateHooks`'s `path: what is wrong` shape. */
|
|
71
|
+
class Report {
|
|
72
|
+
errors = [];
|
|
73
|
+
warnings = [];
|
|
74
|
+
fail(path, problem) {
|
|
75
|
+
this.errors.push(`${path}: ${problem}`);
|
|
76
|
+
}
|
|
77
|
+
drop(path, problem) {
|
|
78
|
+
this.warnings.push(`${path}: ${problem}, and was dropped`);
|
|
79
|
+
}
|
|
80
|
+
note(path, problem) {
|
|
81
|
+
this.warnings.push(`${path}: ${problem}`);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A string field, or nothing. Interpolation is never performed, so a `${VAR}` is reported as
|
|
85
|
+
* the literal it will stay — the alternative hands whoever wrote the document the ability to
|
|
86
|
+
* read any variable the process has, which is why the formats that do it need a credential
|
|
87
|
+
* denylist bolted on afterwards.
|
|
88
|
+
*/
|
|
89
|
+
string(path, value) {
|
|
90
|
+
if (absent(value))
|
|
91
|
+
return undefined;
|
|
92
|
+
if (typeof value !== "string")
|
|
93
|
+
return void this.drop(path, "must be a string");
|
|
94
|
+
if (VARIABLE.test(value))
|
|
95
|
+
this.note(path, "looks like a variable reference, which is never expanded");
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
number(path, value, least, most = Infinity) {
|
|
99
|
+
if (absent(value))
|
|
100
|
+
return undefined;
|
|
101
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
102
|
+
return void this.drop(path, "must be a number");
|
|
103
|
+
// Dropped, never clamped: a clamp invents a number the author did not write, while dropping
|
|
104
|
+
// falls back to one somebody did — the layer below, or nothing.
|
|
105
|
+
if (value < least || value > most)
|
|
106
|
+
return void this.drop(path, `must be ${most === Infinity ? `at least ${least}` : `between ${least} and ${most}`}`);
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
boolean(path, value) {
|
|
110
|
+
if (absent(value))
|
|
111
|
+
return undefined;
|
|
112
|
+
if (typeof value !== "boolean")
|
|
113
|
+
return void this.drop(path, "must be true or false");
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Drops the keys whose value did not survive, so an absent field stays absent. */
|
|
118
|
+
function kept(fields) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const [key, value] of Object.entries(fields))
|
|
121
|
+
if (value !== undefined)
|
|
122
|
+
out[key] = value;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** Whether a parsed sub-object said anything at all. An empty one is the same as an absent one. */
|
|
126
|
+
const said = (value) => Object.keys(value).length > 0;
|
|
127
|
+
function parseEndpoint(report, path, value) {
|
|
128
|
+
if (absent(value))
|
|
129
|
+
return undefined;
|
|
130
|
+
if (!isRecord(value))
|
|
131
|
+
return void report.drop(path, "must be an object");
|
|
132
|
+
const endpoint = kept({
|
|
133
|
+
baseUrl: report.string(`${path}.baseUrl`, value.baseUrl),
|
|
134
|
+
requestTimeoutSeconds: report.number(`${path}.requestTimeoutSeconds`, value.requestTimeoutSeconds, 0),
|
|
135
|
+
firstTokenSeconds: report.number(`${path}.firstTokenSeconds`, value.firstTokenSeconds, 0),
|
|
136
|
+
});
|
|
137
|
+
if (!absent(value.apiKey) || !absent(value.api_key))
|
|
138
|
+
report.drop(`${path}.apiKey`, "is not part of this format — a document carries no credentials");
|
|
139
|
+
return said(endpoint) ? endpoint : undefined;
|
|
140
|
+
}
|
|
141
|
+
function parseModel(report, value) {
|
|
142
|
+
if (absent(value))
|
|
143
|
+
return undefined;
|
|
144
|
+
if (!isRecord(value))
|
|
145
|
+
return void report.drop("model", "must be an object");
|
|
146
|
+
let extraBody;
|
|
147
|
+
if (!absent(value.extraBody)) {
|
|
148
|
+
if (!isRecord(value.extraBody))
|
|
149
|
+
report.drop("model.extraBody", "must be an object");
|
|
150
|
+
else {
|
|
151
|
+
extraBody = {};
|
|
152
|
+
for (const [key, held] of Object.entries(value.extraBody)) {
|
|
153
|
+
if (RESERVED_BODY.includes(key))
|
|
154
|
+
report.drop(`model.extraBody.${key}`, "is the loop's to set");
|
|
155
|
+
else
|
|
156
|
+
extraBody[key] = held;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return kept({
|
|
161
|
+
model: report.string("model.model", value.model),
|
|
162
|
+
maxTokens: report.number("model.maxTokens", value.maxTokens, 0),
|
|
163
|
+
temperature: report.number("model.temperature", value.temperature, 0, 2),
|
|
164
|
+
reasoningEffort: report.string("model.reasoningEffort", value.reasoningEffort),
|
|
165
|
+
contextLength: report.number("model.contextLength", value.contextLength, 0),
|
|
166
|
+
extraBody,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function parsePrompt(report, value) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (const [index, held] of value.entries()) {
|
|
172
|
+
const path = `prompt[${index}]`;
|
|
173
|
+
if (!isRecord(held)) {
|
|
174
|
+
report.drop(path, "must be an object");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const id = report.string(`${path}.id`, held.id);
|
|
178
|
+
if (!id) {
|
|
179
|
+
report.drop(path, "has no id, and merging happens on the id");
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const text = report.string(`${path}.text`, held.text);
|
|
183
|
+
let ref;
|
|
184
|
+
if (!absent(held.ref)) {
|
|
185
|
+
const raw = held.ref;
|
|
186
|
+
const type = isRecord(raw) ? raw.type : undefined;
|
|
187
|
+
const value = isRecord(raw) ? report.string(`${path}.ref.value`, raw.value) : undefined;
|
|
188
|
+
if ((type === "file" || type === "url") && value)
|
|
189
|
+
ref = { type, value };
|
|
190
|
+
else
|
|
191
|
+
report.drop(`${path}.ref`, 'must be { type: "file" | "url", value }');
|
|
192
|
+
}
|
|
193
|
+
// An empty text is how a layer deletes the part below it, so it is not the same as neither.
|
|
194
|
+
if (text === undefined && !ref) {
|
|
195
|
+
report.drop(path, "has neither text nor a reference");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
parts.push(kept({ id, text, ref }));
|
|
199
|
+
}
|
|
200
|
+
return parts;
|
|
201
|
+
}
|
|
202
|
+
function parseTools(report, value) {
|
|
203
|
+
if (absent(value))
|
|
204
|
+
return undefined;
|
|
205
|
+
if (!isRecord(value))
|
|
206
|
+
return void report.drop("tools", "must be an object");
|
|
207
|
+
let discovery;
|
|
208
|
+
if (!absent(value.discovery)) {
|
|
209
|
+
if (value.discovery === "eager" || value.discovery === "ondemand")
|
|
210
|
+
discovery = value.discovery;
|
|
211
|
+
else
|
|
212
|
+
report.drop("tools.discovery", 'must be "eager" or "ondemand"');
|
|
213
|
+
}
|
|
214
|
+
let servers;
|
|
215
|
+
if (!absent(value.servers)) {
|
|
216
|
+
if (!Array.isArray(value.servers))
|
|
217
|
+
report.drop("tools.servers", "must be an array of slugs");
|
|
218
|
+
else {
|
|
219
|
+
// The empty array survives on purpose: it is "no servers", and collapsing it into absent
|
|
220
|
+
// would turn a scoped agent into one holding every tool on the box.
|
|
221
|
+
servers = [];
|
|
222
|
+
for (const [index, slug] of value.servers.entries()) {
|
|
223
|
+
const name = report.string(`tools.servers[${index}]`, slug);
|
|
224
|
+
if (name)
|
|
225
|
+
servers.push(name);
|
|
226
|
+
else if (name === "")
|
|
227
|
+
report.drop(`tools.servers[${index}]`, "is empty");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return kept({
|
|
232
|
+
discovery,
|
|
233
|
+
maxIterations: report.number("tools.maxIterations", value.maxIterations, 1),
|
|
234
|
+
servers,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function parseRetry(report, value) {
|
|
238
|
+
if (absent(value))
|
|
239
|
+
return undefined;
|
|
240
|
+
if (!isRecord(value))
|
|
241
|
+
return void report.drop("retry", "must be an object");
|
|
242
|
+
const retry = kept({
|
|
243
|
+
maxRetries: report.number("retry.maxRetries", value.maxRetries, 0),
|
|
244
|
+
loadingTimeoutSeconds: report.number("retry.loadingTimeoutSeconds", value.loadingTimeoutSeconds, 0),
|
|
245
|
+
});
|
|
246
|
+
return said(retry) ? retry : undefined;
|
|
247
|
+
}
|
|
248
|
+
function parseTasks(report, value) {
|
|
249
|
+
const tasks = {};
|
|
250
|
+
for (const [key, held] of Object.entries(value)) {
|
|
251
|
+
const path = `tasks.${key}`;
|
|
252
|
+
if (absent(held))
|
|
253
|
+
continue;
|
|
254
|
+
if (!isRecord(held)) {
|
|
255
|
+
report.drop(path, "must be an object");
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// An unknown key is not warned about: a host may run side tasks this package has never
|
|
259
|
+
// heard of, and `AGENT_TASKS` is documentation rather than a validator.
|
|
260
|
+
tasks[key] = kept({
|
|
261
|
+
model: report.string(`${path}.model`, held.model),
|
|
262
|
+
endpoint: parseEndpoint(report, `${path}.endpoint`, held.endpoint),
|
|
263
|
+
maxTokens: report.number(`${path}.maxTokens`, held.maxTokens, 0),
|
|
264
|
+
temperature: report.number(`${path}.temperature`, held.temperature, 0, 2),
|
|
265
|
+
reasoningEffort: report.string(`${path}.reasoningEffort`, held.reasoningEffort),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return tasks;
|
|
269
|
+
}
|
|
270
|
+
function parseHooks(report, value, events) {
|
|
271
|
+
const hooks = [];
|
|
272
|
+
for (const [index, held] of value.entries()) {
|
|
273
|
+
const path = `hooks[${index}]`;
|
|
274
|
+
if (!isRecord(held)) {
|
|
275
|
+
report.drop(path, "must be an object");
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const id = report.string(`${path}.id`, held.id);
|
|
279
|
+
const server = report.string(`${path}.server`, held.server);
|
|
280
|
+
const tool = report.string(`${path}.tool`, held.tool);
|
|
281
|
+
const on = report.string(`${path}.on`, held.on);
|
|
282
|
+
if (!id || !server || !tool || !on) {
|
|
283
|
+
report.drop(path, "needs an id, an event, a server and a tool");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!SPEC_EVENTS.includes(on)) {
|
|
287
|
+
report.drop(`${path}.on`, `"${on}" is not an event`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
// A host that never compacts refuses to save a `beforeCompact` hook today, which makes an
|
|
291
|
+
// otherwise good agent unimportable. Kept and noted instead.
|
|
292
|
+
if (!events.includes(on))
|
|
293
|
+
report.note(`${path}.on`, `"${on}" is never fired by this host`);
|
|
294
|
+
let inject = report.boolean(`${path}.inject`, held.inject);
|
|
295
|
+
if (inject && !SPEC_INJECT.has(on)) {
|
|
296
|
+
report.drop(`${path}.inject`, `"${on}" runs after the model has already answered`);
|
|
297
|
+
inject = undefined;
|
|
298
|
+
}
|
|
299
|
+
let veto = report.boolean(`${path}.veto`, held.veto);
|
|
300
|
+
if (veto && on !== "beforeCompact") {
|
|
301
|
+
report.drop(`${path}.veto`, `"${on}" announces nothing a hook can decline`);
|
|
302
|
+
veto = undefined;
|
|
303
|
+
}
|
|
304
|
+
hooks.push(kept({
|
|
305
|
+
id,
|
|
306
|
+
on: on,
|
|
307
|
+
server,
|
|
308
|
+
tool,
|
|
309
|
+
args: absent(held.args) ? undefined : held.args,
|
|
310
|
+
inject,
|
|
311
|
+
veto,
|
|
312
|
+
maxTokens: report.number(`${path}.maxTokens`, held.maxTokens, 0),
|
|
313
|
+
timeoutMs: report.number(`${path}.timeoutMs`, held.timeoutMs, 0),
|
|
314
|
+
enabled: report.boolean(`${path}.enabled`, held.enabled),
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
return hooks;
|
|
318
|
+
}
|
|
319
|
+
function parseBundle(report, value) {
|
|
320
|
+
if (absent(value))
|
|
321
|
+
return undefined;
|
|
322
|
+
if (!isRecord(value))
|
|
323
|
+
return void report.drop("bundle", "must be an object");
|
|
324
|
+
if (absent(value.mcpServers))
|
|
325
|
+
return undefined;
|
|
326
|
+
if (!Array.isArray(value.mcpServers))
|
|
327
|
+
return void report.drop("bundle.mcpServers", "must be an array");
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
const servers = [];
|
|
330
|
+
for (const [index, held] of value.mcpServers.entries()) {
|
|
331
|
+
const path = `bundle.mcpServers[${index}]`;
|
|
332
|
+
if (!isRecord(held)) {
|
|
333
|
+
report.drop(path, "must be an object");
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const slug = typeof held.slug === "string" && held.slug ? held.slug : undefined;
|
|
337
|
+
const id = typeof held.id === "string" && held.id ? held.id : undefined;
|
|
338
|
+
const effective = slug ?? id;
|
|
339
|
+
if (!effective) {
|
|
340
|
+
report.drop(path, "needs an id or a slug");
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (seen.has(effective)) {
|
|
344
|
+
report.drop(path, `is a second server called "${effective}"`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
seen.add(effective);
|
|
348
|
+
// Everything else is passed through untouched, including keys this package has no name for.
|
|
349
|
+
servers.push(held);
|
|
350
|
+
}
|
|
351
|
+
return { mcpServers: servers };
|
|
352
|
+
}
|
|
353
|
+
/** The keys this version names. Anything else is a later 1.x, and is carried rather than erased. */
|
|
354
|
+
const KNOWN = new Set([
|
|
355
|
+
"spec",
|
|
356
|
+
"$schema",
|
|
357
|
+
"id",
|
|
358
|
+
"name",
|
|
359
|
+
"description",
|
|
360
|
+
"endpoint",
|
|
361
|
+
"model",
|
|
362
|
+
"prompt",
|
|
363
|
+
"tools",
|
|
364
|
+
"retry",
|
|
365
|
+
"tasks",
|
|
366
|
+
"hooks",
|
|
367
|
+
"bundle",
|
|
368
|
+
"extensions",
|
|
369
|
+
"requires",
|
|
370
|
+
]);
|
|
371
|
+
/**
|
|
372
|
+
* Reads a document, dropping what it cannot use and refusing only what it cannot identify.
|
|
373
|
+
*
|
|
374
|
+
* Every problem is reported at once, in the `path: what is wrong` shape `validateHooks` already
|
|
375
|
+
* uses, because a host showing an operator one error at a time makes them fix a document one
|
|
376
|
+
* round trip at a time.
|
|
377
|
+
*
|
|
378
|
+
* There are exactly four hard errors, all of them about the document rather than a value: it is
|
|
379
|
+
* not an object; the `spec` token is missing or of another major; a `requires` entry this host
|
|
380
|
+
* does not understand; a container of the wrong kind. Everything else drops the offending field
|
|
381
|
+
* and warns — the lesson a consumer learned the hard way when one stored out-of-range value
|
|
382
|
+
* stopped its server booting. Out-of-range values are dropped rather than clamped, because a
|
|
383
|
+
* clamp invents a number the author did not write while dropping falls back to one somebody did.
|
|
384
|
+
*
|
|
385
|
+
* Version handling is the deliberate opposite of `importCapabilities`, which shrugs at a foreign
|
|
386
|
+
* version and returns false: degrade where the loss is performance, refuse where the loss is
|
|
387
|
+
* identity. A snapshot of the wrong version costs a few refused requests; an agent of the wrong
|
|
388
|
+
* version is not the agent its author described.
|
|
389
|
+
*
|
|
390
|
+
* @param document Anything at all — this is the front door, and it is given parsed JSON from a
|
|
391
|
+
* form, a file or another host.
|
|
392
|
+
* @param options Whether to parse a bundle, which events this host fires, and which extension
|
|
393
|
+
* keys it understands.
|
|
394
|
+
*/
|
|
395
|
+
export function parseSpec(document, options = {}) {
|
|
396
|
+
const report = new Report();
|
|
397
|
+
const events = options.events ?? SPEC_EVENTS;
|
|
398
|
+
if (!isRecord(document)) {
|
|
399
|
+
report.fail("spec", "must be an object");
|
|
400
|
+
return { spec: null, errors: report.errors, warnings: report.warnings };
|
|
401
|
+
}
|
|
402
|
+
const token = document.spec;
|
|
403
|
+
if (typeof token !== "string")
|
|
404
|
+
report.fail("spec", `must be "${AGENT_SPEC}"`);
|
|
405
|
+
else if (token !== AGENT_SPEC) {
|
|
406
|
+
const major = Number(token.split("/")[1]);
|
|
407
|
+
const named = token.startsWith("cubicecho.agent/") && Number.isInteger(major);
|
|
408
|
+
report.fail("spec", named
|
|
409
|
+
? `is version ${major}, and this build reads version ${AGENT_SPEC_VERSION}`
|
|
410
|
+
: `must be "${AGENT_SPEC}"`);
|
|
411
|
+
}
|
|
412
|
+
// Containers are checked before their contents: "prompt must be an array" is a different
|
|
413
|
+
// mistake from "this prompt part has no id", and only the first is worth refusing over.
|
|
414
|
+
if (!absent(document.prompt) && !Array.isArray(document.prompt))
|
|
415
|
+
report.fail("prompt", "must be an array");
|
|
416
|
+
if (!absent(document.tasks) && !isRecord(document.tasks))
|
|
417
|
+
report.fail("tasks", "must be an object");
|
|
418
|
+
if (!absent(document.hooks) && !Array.isArray(document.hooks))
|
|
419
|
+
report.fail("hooks", "must be an array");
|
|
420
|
+
const requires = [];
|
|
421
|
+
if (!absent(document.requires)) {
|
|
422
|
+
if (!Array.isArray(document.requires))
|
|
423
|
+
report.fail("requires", "must be an array");
|
|
424
|
+
else
|
|
425
|
+
for (const [index, key] of document.requires.entries()) {
|
|
426
|
+
if (typeof key !== "string") {
|
|
427
|
+
report.fail(`requires[${index}]`, "must be a string");
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// Fails closed: an agent that must not run without its sandbox extension can say so.
|
|
431
|
+
if (options.understands && !options.understands.includes(key))
|
|
432
|
+
report.fail(`requires[${index}]`, `"${key}" is not understood by this host`);
|
|
433
|
+
requires.push(key);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
let extensions;
|
|
437
|
+
if (!absent(document.extensions)) {
|
|
438
|
+
if (!isRecord(document.extensions))
|
|
439
|
+
report.drop("extensions", "must be an object");
|
|
440
|
+
// Never validated beyond being an object. It is the escape hatch, and the one place a host
|
|
441
|
+
// can put what this package has no name for without being told about it.
|
|
442
|
+
else
|
|
443
|
+
extensions = document.extensions;
|
|
444
|
+
}
|
|
445
|
+
if (!absent(document.bundle) && !options.bundle)
|
|
446
|
+
report.drop("bundle", "carries a command line, so it is only read with { bundle: true }");
|
|
447
|
+
const spec = {
|
|
448
|
+
spec: AGENT_SPEC,
|
|
449
|
+
...kept({
|
|
450
|
+
$schema: report.string("$schema", document.$schema),
|
|
451
|
+
id: report.string("id", document.id),
|
|
452
|
+
name: report.string("name", document.name),
|
|
453
|
+
description: report.string("description", document.description),
|
|
454
|
+
endpoint: parseEndpoint(report, "endpoint", document.endpoint),
|
|
455
|
+
model: parseModel(report, document.model),
|
|
456
|
+
prompt: Array.isArray(document.prompt) ? parsePrompt(report, document.prompt) : undefined,
|
|
457
|
+
tools: parseTools(report, document.tools),
|
|
458
|
+
retry: parseRetry(report, document.retry),
|
|
459
|
+
tasks: isRecord(document.tasks) ? parseTasks(report, document.tasks) : undefined,
|
|
460
|
+
hooks: Array.isArray(document.hooks) ? parseHooks(report, document.hooks, events) : undefined,
|
|
461
|
+
bundle: options.bundle ? parseBundle(report, document.bundle) : undefined,
|
|
462
|
+
extensions,
|
|
463
|
+
requires: absent(document.requires) ? undefined : requires,
|
|
464
|
+
}),
|
|
465
|
+
};
|
|
466
|
+
for (const [key, held] of Object.entries(document)) {
|
|
467
|
+
if (KNOWN.has(key))
|
|
468
|
+
continue;
|
|
469
|
+
// Kept, not erased: a host stores what it was given and resolves on read, so a key from a
|
|
470
|
+
// later 1.x survives an edit-and-export cycle instead of dying at the first host to open it.
|
|
471
|
+
report.note(key, "is not a key this version names, and was carried through unread");
|
|
472
|
+
spec[key] = held;
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
spec: report.errors.length ? null : spec,
|
|
476
|
+
errors: report.errors,
|
|
477
|
+
warnings: report.warnings,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/** Merges the layers that named a key, last one winning, and leaves it absent if none did. */
|
|
481
|
+
function mergeEndpoints(layers) {
|
|
482
|
+
const out = {};
|
|
483
|
+
for (const layer of layers) {
|
|
484
|
+
if (!layer)
|
|
485
|
+
continue;
|
|
486
|
+
if (layer.baseUrl !== undefined)
|
|
487
|
+
out.baseUrl = layer.baseUrl;
|
|
488
|
+
if (layer.requestTimeoutSeconds !== undefined)
|
|
489
|
+
out.requestTimeoutSeconds = layer.requestTimeoutSeconds;
|
|
490
|
+
if (layer.firstTokenSeconds !== undefined)
|
|
491
|
+
out.firstTokenSeconds = layer.firstTokenSeconds;
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
/** An `Endpoint` as the loop takes one. `apiKey` is always empty; see `resolveAgentSpec`. */
|
|
496
|
+
const asEndpoint = (spec) => ({
|
|
497
|
+
baseUrl: spec.baseUrl ?? "",
|
|
498
|
+
apiKey: "",
|
|
499
|
+
...kept({
|
|
500
|
+
requestTimeoutSeconds: spec.requestTimeoutSeconds,
|
|
501
|
+
firstTokenSeconds: spec.firstTokenSeconds,
|
|
502
|
+
}),
|
|
503
|
+
});
|
|
504
|
+
/**
|
|
505
|
+
* Layers documents into the flat object the loop takes, weakest first.
|
|
506
|
+
*
|
|
507
|
+
* Absent inherits, and nothing else does: `0`, `-1`, `""` and `[]` are values that mean what they
|
|
508
|
+
* say, which is the whole reason the format exists — `maxTokens: 0` means three different things
|
|
509
|
+
* across the three applications this was extracted from, and `temperature: 0` is unsayable in one
|
|
510
|
+
* of them. Scalars take the last layer that has the key. `extraBody` and `extensions` merge by
|
|
511
|
+
* top-level key. `prompt`, `hooks` and `tasks` merge by id, ordered by first appearance, which is
|
|
512
|
+
* one rule expressing two behaviours that look unrelated: different ids stack, and the same id
|
|
513
|
+
* replaces. `tools.servers` replaces whole, because a tri-state cannot be unioned. `requires`
|
|
514
|
+
* unions, so a layer above cannot quietly drop a requirement below it.
|
|
515
|
+
*
|
|
516
|
+
* No layer is ever fetched. There is no `extends` field and no URL resolution; which documents
|
|
517
|
+
* layer is the host's call, the same answer `resolveApiKey` gives about keys. `apiKey` comes back
|
|
518
|
+
* empty for the same reason: a document naming somebody else's endpoint must not be able to make
|
|
519
|
+
* this host send its own provider key there. A host that wants inheritance applies `resolveApiKey`
|
|
520
|
+
* afterwards.
|
|
521
|
+
*
|
|
522
|
+
* @param layers The documents, weakest first: settings, then the agent, then a task, then a step.
|
|
523
|
+
*/
|
|
524
|
+
export function resolveAgentSpec(layers) {
|
|
525
|
+
const endpoint = mergeEndpoints(layers.map((layer) => layer.endpoint));
|
|
526
|
+
const model = {};
|
|
527
|
+
const extraBody = {};
|
|
528
|
+
for (const layer of layers) {
|
|
529
|
+
for (const [key, value] of Object.entries(layer.model ?? {})) {
|
|
530
|
+
if (value === undefined)
|
|
531
|
+
continue;
|
|
532
|
+
if (key === "extraBody")
|
|
533
|
+
Object.assign(extraBody, value);
|
|
534
|
+
else
|
|
535
|
+
model[key] = value;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const parts = new Map();
|
|
539
|
+
for (const layer of layers)
|
|
540
|
+
for (const part of layer.prompt ?? [])
|
|
541
|
+
parts.set(part.id, { ...(parts.get(part.id) ?? { id: part.id }), ...part });
|
|
542
|
+
const tools = {};
|
|
543
|
+
for (const layer of layers) {
|
|
544
|
+
if (layer.tools?.discovery !== undefined)
|
|
545
|
+
tools.discovery = layer.tools.discovery;
|
|
546
|
+
if (layer.tools?.maxIterations !== undefined)
|
|
547
|
+
tools.maxIterations = layer.tools.maxIterations;
|
|
548
|
+
// Replaced whole rather than unioned: a list, an empty list and no list are three answers,
|
|
549
|
+
// and a union of the first two is the first, which loses the scoping the operator asked for.
|
|
550
|
+
if (layer.tools?.servers !== undefined)
|
|
551
|
+
tools.servers = [...layer.tools.servers];
|
|
552
|
+
}
|
|
553
|
+
const retry = {};
|
|
554
|
+
for (const layer of layers) {
|
|
555
|
+
if (layer.retry?.maxRetries !== undefined)
|
|
556
|
+
retry.maxRetries = layer.retry.maxRetries;
|
|
557
|
+
if (layer.retry?.loadingTimeoutSeconds !== undefined)
|
|
558
|
+
retry.loadingTimeoutSeconds = layer.retry.loadingTimeoutSeconds;
|
|
559
|
+
}
|
|
560
|
+
const merged = new Map();
|
|
561
|
+
for (const layer of layers)
|
|
562
|
+
for (const [key, task] of Object.entries(layer.tasks ?? {}))
|
|
563
|
+
merged.set(key, {
|
|
564
|
+
...(merged.get(key) ?? {}),
|
|
565
|
+
...task,
|
|
566
|
+
endpoint: mergeEndpoints([merged.get(key)?.endpoint, task.endpoint]),
|
|
567
|
+
});
|
|
568
|
+
const tasks = {};
|
|
569
|
+
for (const [key, task] of merged) {
|
|
570
|
+
// `model: ""` is how a layer declines a task a layer below it configured, so the entry goes
|
|
571
|
+
// rather than coming back with an empty model for the host to interpret.
|
|
572
|
+
if (!task.model)
|
|
573
|
+
continue;
|
|
574
|
+
tasks[key] = kept({
|
|
575
|
+
model: task.model,
|
|
576
|
+
endpoint: asEndpoint(mergeEndpoints([endpoint, task.endpoint])),
|
|
577
|
+
maxTokens: task.maxTokens,
|
|
578
|
+
temperature: task.temperature,
|
|
579
|
+
reasoningEffort: task.reasoningEffort,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const hooks = new Map();
|
|
583
|
+
for (const layer of layers)
|
|
584
|
+
for (const hook of layer.hooks ?? [])
|
|
585
|
+
hooks.set(hook.id, { ...(hooks.get(hook.id) ?? hook), ...hook });
|
|
586
|
+
const servers = new Map();
|
|
587
|
+
for (const layer of layers)
|
|
588
|
+
for (const server of layer.bundle?.mcpServers ?? []) {
|
|
589
|
+
const slug = server.slug ?? server.id;
|
|
590
|
+
if (slug)
|
|
591
|
+
servers.set(slug, { ...(servers.get(slug) ?? {}), ...server });
|
|
592
|
+
}
|
|
593
|
+
const extensions = {};
|
|
594
|
+
for (const layer of layers)
|
|
595
|
+
Object.assign(extensions, layer.extensions ?? {});
|
|
596
|
+
const requires = new Set();
|
|
597
|
+
for (const layer of layers)
|
|
598
|
+
for (const key of layer.requires ?? [])
|
|
599
|
+
requires.add(key);
|
|
600
|
+
const id = [...layers].reverse().find((layer) => layer.id)?.id ?? "";
|
|
601
|
+
const name = [...layers].reverse().find((layer) => layer.name)?.name;
|
|
602
|
+
const description = [...layers].reverse().find((layer) => layer.description)?.description;
|
|
603
|
+
const prompt = [...parts.values()];
|
|
604
|
+
return {
|
|
605
|
+
id,
|
|
606
|
+
name: name ?? id,
|
|
607
|
+
description: description ?? "",
|
|
608
|
+
baseUrl: endpoint.baseUrl ?? "",
|
|
609
|
+
apiKey: "",
|
|
610
|
+
...kept({
|
|
611
|
+
requestTimeoutSeconds: endpoint.requestTimeoutSeconds,
|
|
612
|
+
firstTokenSeconds: endpoint.firstTokenSeconds,
|
|
613
|
+
loadingTimeoutSeconds: retry.loadingTimeoutSeconds,
|
|
614
|
+
reasoningEffort: model.reasoningEffort,
|
|
615
|
+
extraBody: said(extraBody) ? extraBody : undefined,
|
|
616
|
+
servers: tools.servers,
|
|
617
|
+
}),
|
|
618
|
+
model: model.model ?? "",
|
|
619
|
+
maxTokens: model.maxTokens ?? RESOLVED_DEFAULTS.maxTokens,
|
|
620
|
+
temperature: model.temperature ?? RESOLVED_DEFAULTS.temperature,
|
|
621
|
+
contextLength: model.contextLength ?? RESOLVED_DEFAULTS.contextLength,
|
|
622
|
+
toolDiscovery: tools.discovery ?? RESOLVED_DEFAULTS.toolDiscovery,
|
|
623
|
+
toolSelectModel: tasks.toolSelect?.model ?? "",
|
|
624
|
+
maxToolIterations: tools.maxIterations ?? RESOLVED_DEFAULTS.maxToolIterations,
|
|
625
|
+
maxRetries: retry.maxRetries ?? RESOLVED_DEFAULTS.maxRetries,
|
|
626
|
+
// Byte-identical to what a consumer's `systemPromptFor` produces today, so four sources
|
|
627
|
+
// survive a round trip instead of flattening into one blob nobody can edit a third of.
|
|
628
|
+
systemPrompt: prompt
|
|
629
|
+
.map((part) => part.text?.trim() ?? "")
|
|
630
|
+
.filter(Boolean)
|
|
631
|
+
.join("\n\n"),
|
|
632
|
+
prompt,
|
|
633
|
+
tasks,
|
|
634
|
+
hooks: [...hooks.values()],
|
|
635
|
+
mcpServers: [...servers.values()],
|
|
636
|
+
extensions,
|
|
637
|
+
requires: [...requires],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
|
|
642
|
+
*
|
|
643
|
+
* The property worth keeping is that **a redacted spec is still a valid spec** — it parses, it
|
|
644
|
+
* resolves, and what comes back is the same agent pointed at the same servers, needing only the
|
|
645
|
+
* credentials the importing host supplies itself. So this removes values rather than keys, and
|
|
646
|
+
* nothing outside `bundle` is touched: the format has no credential field at any depth, which is
|
|
647
|
+
* the reason there is nothing else to strip.
|
|
648
|
+
*
|
|
649
|
+
* @param spec A document, ordinarily one `parseSpec` returned.
|
|
650
|
+
* @param options Whether to keep the secrets. The default is not to.
|
|
651
|
+
*/
|
|
652
|
+
export function exportSpec(spec, { secrets = false } = {}) {
|
|
653
|
+
if (secrets || !spec.bundle?.mcpServers)
|
|
654
|
+
return { ...spec };
|
|
655
|
+
return {
|
|
656
|
+
...spec,
|
|
657
|
+
bundle: {
|
|
658
|
+
...spec.bundle,
|
|
659
|
+
mcpServers: spec.bundle.mcpServers.map(({ env: _env, headers: _headers, ...rest }) => rest),
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|