@cubicecho/agent-core 2.17.0 → 2.18.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 CHANGED
@@ -38,6 +38,7 @@ only, Node >=22.
38
38
  | `tool-calls` | Reading what a model meant by a tool call it did not write cleanly: `parseToolArguments` repairs almost-JSON arguments and says when they were cut off, `recoverToolCalls` finds calls written into the reply as text. |
39
39
  | `compaction` | Keeping a long run inside its window: `pruneToolResults` clears stale tool results, `planCompaction` and `compactTranscript` fold the oldest stretch into a summary. |
40
40
  | `snapshot` | `exportCapabilities` and `importCapabilities`: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again. |
41
+ | `spec` | `parseSpec`, `resolveAgentSpec` and `exportSpec`: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at `@cubicecho/agent-core/spec`. |
41
42
  | `reset` | `resetAll`: drops every cache and latch in one call, so a teardown cannot forget one. |
42
43
  | `tokens` | `estimateTokens`: characters over four, deliberately low, for everything here that has to guess at a window. |
43
44
  | `errors` | `errorMessage`: a caught `unknown` turned into something a run row can hold. |
@@ -822,6 +823,57 @@ The seam is not finished. `timeoutMs` narrows to the one field it reads, but `ge
822
823
  asks for the whole of `Endpoint`. `requestTimeoutSeconds` became optional in v2, so a consumer
823
824
  with no timeout to give now leaves it out rather than inventing a `0`.
824
825
 
826
+ ## An agent as a document
827
+
828
+ The seam above says what a *running* agent is. `@cubicecho/agent-core/spec` says what a **stored**
829
+ one is: a versioned JSON document that any host on this package can read, layer and hand to the
830
+ loop. It exists because the applications here spell "an agent" three incompatible ways — `maxTokens: 0`
831
+ means *no ceiling* in one, *inherit* in the next and *a real ceiling of zero* in the third, and an
832
+ empty server list means *no tools* in one and *every tool* in another. `docs/agent-spec.md` argues
833
+ the whole thing out.
834
+
835
+ ```ts
836
+ import { parseSpec, resolveAgentSpec } from "@cubicecho/agent-core/spec";
837
+
838
+ const { spec, errors, warnings } = parseSpec(JSON.parse(text));
839
+ if (!spec) throw new Error(errors.join("\n"));
840
+
841
+ const config = resolveAgentSpec([settingsSpec, spec]); // weakest layer first
842
+ const result = await runAgentLoop({ config, ... }); // already the shape the loop takes
843
+ ```
844
+
845
+ **Absent inherits, and nothing else does.** There is no inherit sentinel at any depth: `0`, `-1`,
846
+ `""` and `[]` are values that mean what they say, and `null` reads as absent because a nullable
847
+ column round-trips through JSON that way. `tools.servers` keeps its tri-state — absent is every
848
+ server the host offers, `[]` is none, a list is exactly those — and layering replaces it whole
849
+ rather than unioning, since a union of "these two" and "none" is "these two", which silently widens
850
+ a scoped agent. `prompt`, `hooks` and `tasks` merge by id, which is one rule expressing two
851
+ behaviours: different ids stack, the same id replaces.
852
+
853
+ `parseSpec` reports every problem at once and refuses only four things, all of them about the
854
+ document rather than a value: it is not an object, the `spec` token is missing or of another major,
855
+ a `requires` entry this host does not understand, or a container is of the wrong kind. Everything
856
+ else is dropped with a warning — including an out-of-range number, which is **dropped rather than
857
+ clamped**, because a clamp invents a value the author did not write while dropping falls back to one
858
+ somebody did.
859
+
860
+ The document carries **no credential at any depth**, and a `${VAR}`-shaped string is kept as the
861
+ literal it is rather than expanded — a format with no interpolation needs no credential denylist.
862
+ Resolution yields `apiKey: ""`, which `getClient` turns into `NO_KEY`; a host that wants inheritance
863
+ applies `resolveApiKey` afterwards, so a document naming somebody else's endpoint cannot make this
864
+ host send its own provider key there. `exportSpec(spec)` strips a bundled server's `env` and
865
+ `headers`, and a redacted document is still a valid one.
866
+
867
+ `bundle.mcpServers` is parsed only under `parseSpec(document, { bundle: true })`. A bundled server
868
+ is a command line and an environment map, so importing one is equivalent to running a program, and
869
+ that is a decision a host makes rather than inherits from a default.
870
+
871
+ The subpath is separate from the main entry for one reason: `client.ts` and `hooks.ts` import
872
+ `node:crypto`, and `spec.ts` imports nothing but types, so a browser validating a pasted document in
873
+ a form can load it. Nothing in `src/` imports it — the same direction the config seam runs, which is
874
+ what lets a document travel between hosts without the loop growing an opinion about where an agent
875
+ comes from.
876
+
825
877
  ## What is kept for the life of the process
826
878
 
827
879
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
package/dist/index.d.ts CHANGED
@@ -26,6 +26,7 @@ export { type RunTurnOptions, runTurn } from "./run-turn.ts";
26
26
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.ts";
27
27
  export { type AskJsonOptions, ask, askJson, clean, listLines, parseJson, resetHints, type SideTaskInput, type SideTaskOptions, tryAsk, } from "./side-task.ts";
28
28
  export { CAPABILITY_SNAPSHOT_VERSION, type CapabilitySnapshot, type EndpointSnapshot, exportCapabilities, importCapabilities, type ModelSnapshot, } from "./snapshot.ts";
29
+ export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, type AgentHook, type AgentSpec, type EndpointSpec, type ExportSpecOptions, exportSpec, type ModelSpec, type ParsedSpec, type ParseSpecOptions, type PromptPart, parseSpec, RESOLVED_DEFAULTS, type ResolvedAgent, type ResolvedTask, type RetrySpec, resolveAgentSpec, SPEC_EVENTS, type SpecBundle, type SpecServer, type TaskSpec, type ToolsSpec, } from "./spec.ts";
29
30
  export { type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from "./stream.ts";
30
31
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, type Split, stripThinking, THINK_FENCE, } from "./thinking.ts";
31
32
  export { estimateTokens } from "./tokens.ts";
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export { runTurn } from "./run-turn.js";
24
24
  export { isGrammarError, relaxTools, sanitizeTools } from "./schema-compat.js";
25
25
  export { ask, askJson, clean, listLines, parseJson, resetHints, tryAsk, } from "./side-task.js";
26
26
  export { CAPABILITY_SNAPSHOT_VERSION, exportCapabilities, importCapabilities, } from "./snapshot.js";
27
+ export { AGENT_SPEC, AGENT_SPEC_VERSION, AGENT_TASKS, exportSpec, parseSpec, RESOLVED_DEFAULTS, resolveAgentSpec, SPEC_EVENTS, } from "./spec.js";
27
28
  export { streamTurn, } from "./stream.js";
28
29
  export { ALL_FENCES, DEFAULT_FENCES, FenceSplitter, stripThinking, THINK_FENCE, } from "./thinking.js";
29
30
  export { estimateTokens } from "./tokens.js";
package/dist/spec.d.ts ADDED
@@ -0,0 +1,319 @@
1
+ import type { AgentConfig, Endpoint } from "./config.ts";
2
+ import type { HookEvent } from "./hooks.ts";
3
+ /**
4
+ * A JSON document that defines an agent, and the rules for reading one.
5
+ *
6
+ * Four applications sit on this package and each one spells "an agent" differently: a singleton
7
+ * settings row, a deliberately jobless agent table whose job comes from three other rows, an
8
+ * override profile that resolves into a whole synthesised settings object. None of them can read
9
+ * another's agents, and `maxTokens: 0` means *no ceiling* in one, *inherit* in the next and *a
10
+ * real ceiling of zero* in the third. This is the shape they can all write and all read, argued
11
+ * out in `docs/agent-spec.md` before any of it was code.
12
+ *
13
+ * Nothing else in `src/` imports this module, and this module imports nothing but types. That is
14
+ * the same seam `config.ts` draws — the loop takes the parts it reads, not a config object — and
15
+ * it is what lets the document travel between hosts without the loop growing an opinion about
16
+ * where an agent comes from. It is also why this is importable in a browser, where `client.ts`
17
+ * and `hooks.ts` are not: a host validating a pasted document in a form needs no `node:crypto`.
18
+ */
19
+ /** The token a document declares itself with. A loader refuses anything else. */
20
+ export declare const AGENT_SPEC = "cubicecho.agent/1";
21
+ /** The major version `AGENT_SPEC` pins. Raised only when a field changes meaning. */
22
+ export declare const AGENT_SPEC_VERSION = 1;
23
+ /**
24
+ * The side tasks this package has code for: documentation and a sensible UI order, not a
25
+ * validator.
26
+ *
27
+ * `tasks` is an open record on purpose — a host may run tasks this package has never heard of,
28
+ * and an unknown key is carried through untouched rather than warned about.
29
+ */
30
+ export declare const AGENT_TASKS: readonly ["compaction", "toolSelect", "title", "followups"];
31
+ /**
32
+ * Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
33
+ *
34
+ * `hooks.ts` owns the list; importing its `HOOK_EVENTS` would pull the whole module, and its
35
+ * `createHash` with it, into a file whose whole point is that a browser can load it. The `HookEvent`
36
+ * type is imported and erased, so a removed event fails to compile here, and a test asserts this
37
+ * list and `HOOK_EVENTS` are the same list so an added one cannot drift quietly.
38
+ */
39
+ export declare const SPEC_EVENTS: readonly HookEvent[];
40
+ /** One agent, as a document. Every field but `spec` is optional, because a layer says only what it changes. */
41
+ export interface AgentSpec {
42
+ /** Exactly `AGENT_SPEC`. Missing, mistyped or another major is refused. */
43
+ spec: typeof AGENT_SPEC;
44
+ /** A URL for an editor's autocompletion. Never fetched, never read, never validated against. */
45
+ $schema?: string;
46
+ /** A stable handle within a host: `reviewer`, `local-qwen`. Absent in a layer of pure defaults. */
47
+ id?: string;
48
+ /** What a person calls it. Absent falls back to `id`. */
49
+ name?: string;
50
+ /** One line for an operator choosing between agents. Never sent to a model — that is `prompt`. */
51
+ description?: string;
52
+ endpoint?: EndpointSpec;
53
+ model?: ModelSpec;
54
+ /** Ordered layers of system prompt, merged by `id` and joined with blank lines on resolve. */
55
+ prompt?: PromptPart[];
56
+ tools?: ToolsSpec;
57
+ retry?: RetrySpec;
58
+ /** Side tasks run on a model other than the agent's. Open by design; see `AGENT_TASKS`. */
59
+ tasks?: Record<string, TaskSpec>;
60
+ hooks?: AgentHook[];
61
+ /** Definitions carried so an agent travels whole. Executable by proxy; see `parseSpec`. */
62
+ bundle?: SpecBundle;
63
+ /** Host payloads keyed by reverse domain. Carries no semantics and is never validated. */
64
+ extensions?: Record<string, unknown>;
65
+ /** Extension keys a host must understand, or it refuses to run this agent. */
66
+ requires?: string[];
67
+ /** Keys from a later 1.x, kept so an edit-and-export cycle does not erase them. */
68
+ [key: string]: unknown;
69
+ }
70
+ /** Where a model is reached, and how long to wait for it. There is no credential field, by design. */
71
+ export interface EndpointSpec {
72
+ /** Any OpenAI-compatible base URL. Absent inherits; absent everywhere means it cannot run. */
73
+ baseUrl?: string;
74
+ /** Seconds of silence allowed between chunks. Zero is no limit. */
75
+ requestTimeoutSeconds?: number;
76
+ /** Seconds allowed for the first chunk. Zero is no limit; absent is five times the above. */
77
+ firstTokenSeconds?: number;
78
+ }
79
+ /** Which model answers and how, including the request fields this interface cannot spell. */
80
+ export interface ModelSpec {
81
+ /** Absent inherits. Empty names no model, which cannot run. */
82
+ model?: string;
83
+ /** The reply's ceiling. Zero sends none and lets the server decide — a value, not a sentinel. */
84
+ maxTokens?: number;
85
+ /** Zero to two. Zero means zero. */
86
+ temperature?: number;
87
+ /** `reasoning_effort`. `"off"` and `""` send nothing; any other string passes through. */
88
+ reasoningEffort?: string;
89
+ /** What the operator says this model reads, in tokens. Zero asks the endpoint. */
90
+ contextLength?: number;
91
+ /** Request fields the interface cannot spell. `model`, `messages`, `stream`, `tools` are dropped. */
92
+ extraBody?: Record<string, unknown>;
93
+ }
94
+ /** One layer of system prompt, named so a layer above can stack beside it or replace it. */
95
+ export interface PromptPart {
96
+ /** Names this layer: `project`, `identity`, `role`, `lane`. Merging happens on it. */
97
+ id: string;
98
+ /** The text. Empty deletes the part a layer below contributed. Absent only when `ref` is given. */
99
+ text?: string;
100
+ /** Where the text lives instead. The shape is validated; the reference is never resolved. */
101
+ ref?: {
102
+ type: "file" | "url";
103
+ value: string;
104
+ };
105
+ }
106
+ /** How tools reach the model, and which servers this agent may reach at all. */
107
+ export interface ToolsSpec {
108
+ discovery?: "eager" | "ondemand";
109
+ /** The hard stop on a tool loop. At least one. */
110
+ maxIterations?: number;
111
+ /**
112
+ * The MCP servers this agent may reach, by effective slug (`row.slug ?? row.id`).
113
+ * Absent is every server the host offers, `[]` is none, and a list is exactly those.
114
+ * Three different answers, and a loader must never collapse one into another.
115
+ */
116
+ servers?: string[];
117
+ }
118
+ /** What to do about a request lost before the model produced anything. */
119
+ export interface RetrySpec {
120
+ /** Re-sends of a request lost before the model produced anything. Zero is none — a value. */
121
+ maxRetries?: number;
122
+ /** Seconds to wait out a server loading its model. Zero is not at all; absent is two minutes. */
123
+ loadingTimeoutSeconds?: number;
124
+ }
125
+ /** One side task, which may run on a different model — and a different machine — than the agent. */
126
+ export interface TaskSpec {
127
+ /** The model for this task. Empty turns it off, which is how a layer declines one below it. */
128
+ model?: string;
129
+ /** Where that model lives, when it is not on the agent's endpoint. */
130
+ endpoint?: EndpointSpec;
131
+ maxTokens?: number;
132
+ temperature?: number;
133
+ reasoningEffort?: string;
134
+ }
135
+ /** A tool call bound to a lifecycle event, run by the host rather than the model. */
136
+ export interface AgentHook {
137
+ /** Unique within the agent. Merging happens on it. */
138
+ id: string;
139
+ /** One of `SPEC_EVENTS`. An event this host never fires is a warning, not an error. */
140
+ on: HookEvent;
141
+ /** The server whose tool this is, by effective slug. */
142
+ server: string;
143
+ /** The server's own name for the tool, not the qualified one the model sees. */
144
+ tool: string;
145
+ /** Arguments as JSON, with `{{path}}` placeholders filled from the event's context. */
146
+ args?: unknown;
147
+ /** Hand what the tool returns to the model. Only the events that run before one may. */
148
+ inject?: boolean;
149
+ /** Let this hook decline what its event announces. `beforeCompact` only. */
150
+ veto?: boolean;
151
+ maxTokens?: number;
152
+ timeoutMs?: number;
153
+ /** `false` keeps the hook in the document without running it. */
154
+ enabled?: boolean;
155
+ }
156
+ /** One bundled MCP server, as far as this package reads it. */
157
+ /** A bundled MCP server, validated only as far as a round trip needs and otherwise passed through. */
158
+ export interface SpecServer {
159
+ id?: string;
160
+ slug?: string;
161
+ [key: string]: unknown;
162
+ }
163
+ /** What an agent carries so it can travel whole: the definitions of the servers it names. */
164
+ export interface SpecBundle {
165
+ /**
166
+ * Full MCP server definitions. Validated only as far as a round trip needs — an object, a
167
+ * non-empty `id` or `slug`, no duplicate slugs — with every other key passed through untouched.
168
+ *
169
+ * This package deliberately does not restate the pool's `McpServerConfig`. Restating a union it
170
+ * never reads is a version-skew generator: the pool carries `coerceArguments`, `maxResultChars`
171
+ * and three separate timeouts, and a strict parse here would silently delete a server's
172
+ * ninety-second call timeout on a round trip. Importing the type is not an option either, since
173
+ * the pool pulls the MCP SDK and `openai` is this package's only peer dependency.
174
+ */
175
+ mcpServers?: SpecServer[];
176
+ }
177
+ /** One side task, resolved: a model, and the endpoint it is reached through. */
178
+ export interface ResolvedTask {
179
+ model: string;
180
+ /** This task's endpoint, which is the agent's unless a layer named another. */
181
+ endpoint: Endpoint;
182
+ maxTokens?: number;
183
+ temperature?: number;
184
+ reasoningEffort?: string;
185
+ }
186
+ /**
187
+ * What layered documents resolve to: the flat shape the loop already takes, plus the parts of an
188
+ * agent that are the host's to act on.
189
+ *
190
+ * `extends AgentConfig` is the whole point — `getClient(resolved)` and
191
+ * `runAgentLoop({ config: resolved })` take this unchanged, and no adapter is needed.
192
+ */
193
+ export interface ResolvedAgent extends AgentConfig {
194
+ id: string;
195
+ name: string;
196
+ description: string;
197
+ /** The tri-state preserved: absent is every server the host offers, empty is none. */
198
+ servers?: readonly string[];
199
+ /**
200
+ * The merged prompt parts, in order. `systemPrompt` is the join of their texts, so a part that
201
+ * is only a `ref` contributes nothing to it — the host resolves the reference and re-resolves,
202
+ * or reads it from here and does what it likes.
203
+ */
204
+ prompt: readonly PromptPart[];
205
+ /** Only the tasks a layer configured and none declined. `toolSelect` also flattens to `toolSelectModel`. */
206
+ tasks: Readonly<Record<string, ResolvedTask>>;
207
+ hooks: readonly AgentHook[];
208
+ /** Bundled server definitions, merged by effective slug. Empty unless a bundle was parsed. */
209
+ mcpServers: readonly SpecServer[];
210
+ extensions: Readonly<Record<string, unknown>>;
211
+ /** Every layer's requirements, unioned, so a layer above cannot quietly drop one below it. */
212
+ requires: readonly string[];
213
+ }
214
+ /**
215
+ * What a field falls back to when no layer said anything at all.
216
+ *
217
+ * Not sentinels, and not invented: `toolDiscovery`, `maxRetries` and `contextLength` are the
218
+ * "absent everywhere" column of the document's own table, and `temperature` and `maxToolIterations`
219
+ * are what all three consumers store as their defaults today. A field left out here is left out of
220
+ * the resolved object too, which is how `requestTimeoutSeconds` keeps meaning "no opinion".
221
+ */
222
+ export declare const RESOLVED_DEFAULTS: {
223
+ readonly temperature: 0.7;
224
+ readonly maxTokens: 0;
225
+ readonly contextLength: 0;
226
+ readonly toolDiscovery: "eager";
227
+ readonly maxToolIterations: 20;
228
+ readonly maxRetries: 0;
229
+ };
230
+ /** What `parseSpec` hands back. A refused document is `spec: null` with the reasons in `errors`. */
231
+ export interface ParsedSpec {
232
+ /** The document with everything that did not survive dropped, or null if it was refused. */
233
+ spec: AgentSpec | null;
234
+ /** Why it was refused. Empty when it was not. */
235
+ errors: string[];
236
+ /** What was dropped, and what is worth an operator's attention. Never a reason to refuse. */
237
+ warnings: string[];
238
+ }
239
+ /** What `parseSpec` takes besides the document. */
240
+ export interface ParseSpecOptions {
241
+ /**
242
+ * Parse `bundle` rather than dropping it. Off by default, because a bundled server carries a
243
+ * command line and an environment map: loading one is equivalent to running a program, exactly
244
+ * as dangerous as pasting somebody's `.mcp.json`, and a host has to make that decision rather
245
+ * than inherit it from a default.
246
+ */
247
+ bundle?: boolean;
248
+ /** The events this host actually fires, defaulting to all of them. A hook bound elsewhere warns. */
249
+ events?: readonly string[];
250
+ /** The `extensions` keys this host understands. A `requires` entry outside it refuses the load. */
251
+ understands?: readonly string[];
252
+ }
253
+ /**
254
+ * Reads a document, dropping what it cannot use and refusing only what it cannot identify.
255
+ *
256
+ * Every problem is reported at once, in the `path: what is wrong` shape `validateHooks` already
257
+ * uses, because a host showing an operator one error at a time makes them fix a document one
258
+ * round trip at a time.
259
+ *
260
+ * There are exactly four hard errors, all of them about the document rather than a value: it is
261
+ * not an object; the `spec` token is missing or of another major; a `requires` entry this host
262
+ * does not understand; a container of the wrong kind. Everything else drops the offending field
263
+ * and warns — the lesson a consumer learned the hard way when one stored out-of-range value
264
+ * stopped its server booting. Out-of-range values are dropped rather than clamped, because a
265
+ * clamp invents a number the author did not write while dropping falls back to one somebody did.
266
+ *
267
+ * Version handling is the deliberate opposite of `importCapabilities`, which shrugs at a foreign
268
+ * version and returns false: degrade where the loss is performance, refuse where the loss is
269
+ * identity. A snapshot of the wrong version costs a few refused requests; an agent of the wrong
270
+ * version is not the agent its author described.
271
+ *
272
+ * @param document Anything at all — this is the front door, and it is given parsed JSON from a
273
+ * form, a file or another host.
274
+ * @param options Whether to parse a bundle, which events this host fires, and which extension
275
+ * keys it understands.
276
+ */
277
+ export declare function parseSpec(document: unknown, options?: ParseSpecOptions): ParsedSpec;
278
+ /**
279
+ * Layers documents into the flat object the loop takes, weakest first.
280
+ *
281
+ * Absent inherits, and nothing else does: `0`, `-1`, `""` and `[]` are values that mean what they
282
+ * say, which is the whole reason the format exists — `maxTokens: 0` means three different things
283
+ * across the three applications this was extracted from, and `temperature: 0` is unsayable in one
284
+ * of them. Scalars take the last layer that has the key. `extraBody` and `extensions` merge by
285
+ * top-level key. `prompt`, `hooks` and `tasks` merge by id, ordered by first appearance, which is
286
+ * one rule expressing two behaviours that look unrelated: different ids stack, and the same id
287
+ * replaces. `tools.servers` replaces whole, because a tri-state cannot be unioned. `requires`
288
+ * unions, so a layer above cannot quietly drop a requirement below it.
289
+ *
290
+ * No layer is ever fetched. There is no `extends` field and no URL resolution; which documents
291
+ * layer is the host's call, the same answer `resolveApiKey` gives about keys. `apiKey` comes back
292
+ * empty for the same reason: a document naming somebody else's endpoint must not be able to make
293
+ * this host send its own provider key there. A host that wants inheritance applies `resolveApiKey`
294
+ * afterwards.
295
+ *
296
+ * @param layers The documents, weakest first: settings, then the agent, then a task, then a step.
297
+ */
298
+ export declare function resolveAgentSpec(layers: readonly AgentSpec[]): ResolvedAgent;
299
+ /** What `exportSpec` takes besides the document. */
300
+ export interface ExportSpecOptions {
301
+ /**
302
+ * Keep the environment and headers of bundled servers. Off by default, mirroring the pool's
303
+ * `state({ secrets })`, because those are where a bundled server's credentials live.
304
+ */
305
+ secrets?: boolean;
306
+ }
307
+ /**
308
+ * A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
309
+ *
310
+ * The property worth keeping is that **a redacted spec is still a valid spec** — it parses, it
311
+ * resolves, and what comes back is the same agent pointed at the same servers, needing only the
312
+ * credentials the importing host supplies itself. So this removes values rather than keys, and
313
+ * nothing outside `bundle` is touched: the format has no credential field at any depth, which is
314
+ * the reason there is nothing else to strip.
315
+ *
316
+ * @param spec A document, ordinarily one `parseSpec` returned.
317
+ * @param options Whether to keep the secrets. The default is not to.
318
+ */
319
+ export declare function exportSpec(spec: AgentSpec, { secrets }?: ExportSpecOptions): AgentSpec;
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
+ }
@@ -107,21 +107,30 @@ export declare function orderTools(tools: OpenAI.ChatCompletionTool[], order?: T
107
107
  export declare const MAX_PER_LOAD = 12;
108
108
  /**
109
109
  * The most a conversation carries between turns. Bounds the tool array no matter how long the
110
- * conversation runs; least-recently-used names fall off the front.
110
+ * conversation runs; past it, the earliest-declared names this turn did not use fall off first.
111
111
  *
112
112
  * Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
113
113
  * carry. See `carryOver`, which takes another number if this one is not yours.
114
114
  */
115
115
  export declare const MAX_CARRIED = 16;
116
116
  /**
117
- * The tools to start the next turn with: recently used, newest last, capped.
118
- *
119
- * @param previous Last turn's names, oldest first.
120
- * @param used What this turn called. Moved to the end, so the oldest unused fall off.
121
- * @param max How many to carry, defaulting to `MAX_CARRIED`. At least one: a cap of zero is
122
- * read as no cap, which is what `slice` does with it and not what anybody asking for zero meant.
117
+ * The tools to start the next turn with: last turn's where they were, then what this turn used.
118
+ *
119
+ * Nothing carried moves. A template renders the tool array near the head of the prompt, and
120
+ * moving each used tool to the end — which this did until it was found in a local server's cache
121
+ * log — reordered the array between turns and re-prefilled the whole transcript from the first
122
+ * moved definition. Kept in place, the next turn's array is this one's with only the unused
123
+ * loads gone, a prefix of it when every load was used. Past `max`, dropping a name from the
124
+ * middle costs the cache from there, so it happens only when the cap forces it.
125
+ *
126
+ * @param previous Last turn's names, in the order they were declared.
127
+ * @param used What this turn called. Names not already carried are appended in the order given,
128
+ * so pass them in load order to keep them where the tool array had them.
129
+ * @param max How many to carry, defaulting to `MAX_CARRIED`. Past it, the earliest names not in
130
+ * `used` go first, then the earliest of all. At least one: a cap of zero is read as one, not as
131
+ * no cap and not as none.
123
132
  */
124
- export declare const carryOver: (previous: string[], used: Set<string>, max?: number) => string[];
133
+ export declare function carryOver(previous: readonly string[], used: ReadonlySet<string>, max?: number): string[];
125
134
  /**
126
135
  * Resolves requested names against the catalogue, expanding trailing `*` wildcards.
127
136
  *
@@ -174,21 +174,38 @@ export function orderTools(tools, order = true) {
174
174
  export const MAX_PER_LOAD = 12;
175
175
  /**
176
176
  * The most a conversation carries between turns. Bounds the tool array no matter how long the
177
- * conversation runs; least-recently-used names fall off the front.
177
+ * conversation runs; past it, the earliest-declared names this turn did not use fall off first.
178
178
  *
179
179
  * Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
180
180
  * carry. See `carryOver`, which takes another number if this one is not yours.
181
181
  */
182
182
  export const MAX_CARRIED = 16;
183
183
  /**
184
- * The tools to start the next turn with: recently used, newest last, capped.
185
- *
186
- * @param previous Last turn's names, oldest first.
187
- * @param used What this turn called. Moved to the end, so the oldest unused fall off.
188
- * @param max How many to carry, defaulting to `MAX_CARRIED`. At least one: a cap of zero is
189
- * read as no cap, which is what `slice` does with it and not what anybody asking for zero meant.
184
+ * The tools to start the next turn with: last turn's where they were, then what this turn used.
185
+ *
186
+ * Nothing carried moves. A template renders the tool array near the head of the prompt, and
187
+ * moving each used tool to the end — which this did until it was found in a local server's cache
188
+ * log — reordered the array between turns and re-prefilled the whole transcript from the first
189
+ * moved definition. Kept in place, the next turn's array is this one's with only the unused
190
+ * loads gone, a prefix of it when every load was used. Past `max`, dropping a name from the
191
+ * middle costs the cache from there, so it happens only when the cap forces it.
192
+ *
193
+ * @param previous Last turn's names, in the order they were declared.
194
+ * @param used What this turn called. Names not already carried are appended in the order given,
195
+ * so pass them in load order to keep them where the tool array had them.
196
+ * @param max How many to carry, defaulting to `MAX_CARRIED`. Past it, the earliest names not in
197
+ * `used` go first, then the earliest of all. At least one: a cap of zero is read as one, not as
198
+ * no cap and not as none.
190
199
  */
191
- export const carryOver = (previous, used, max = MAX_CARRIED) => [...previous.filter((name) => !used.has(name)), ...used].slice(-Math.max(1, max));
200
+ export function carryOver(previous, used, max = MAX_CARRIED) {
201
+ const next = [...previous, ...[...used].filter((name) => !previous.includes(name))];
202
+ const cap = Math.max(1, max);
203
+ while (next.length > cap) {
204
+ const stale = next.findIndex((name) => !used.has(name));
205
+ next.splice(stale === -1 ? 0 : stale, 1);
206
+ }
207
+ return next;
208
+ }
192
209
  /**
193
210
  * Resolves requested names against the catalogue, expanding trailing `*` wildcards.
194
211
  *
package/llms.txt CHANGED
@@ -234,6 +234,34 @@ What endpoints and models refused, carried across a restart.
234
234
  - `importCapabilities` — Latches what a stored snapshot says was refused, on top of whatever this process has learned.
235
235
  - `ModelSnapshot` (type) — What one model on an endpoint refused.
236
236
 
237
+ ### spec
238
+
239
+ A JSON document that defines an agent, and the rules for reading one.
240
+
241
+ - `AGENT_SPEC` — The token a document declares itself with.
242
+ - `AGENT_SPEC_VERSION` — The major version `AGENT_SPEC` pins.
243
+ - `AGENT_TASKS` — The side tasks this package has code for: documentation and a sensible UI order, not a validator.
244
+ - `AgentHook` (type) — A tool call bound to a lifecycle event, run by the host rather than the model.
245
+ - `AgentSpec` (type) — One agent, as a document.
246
+ - `EndpointSpec` (type) — Where a model is reached, and how long to wait for it.
247
+ - `ExportSpecOptions` (type) — What `exportSpec` takes besides the document.
248
+ - `exportSpec` — A document fit to leave the host: the same agent, with the secrets of its bundled servers gone.
249
+ - `ModelSpec` (type) — Which model answers and how, including the request fields this interface cannot spell.
250
+ - `ParsedSpec` (type) — What `parseSpec` hands back.
251
+ - `ParseSpecOptions` (type) — What `parseSpec` takes besides the document.
252
+ - `PromptPart` (type) — One layer of system prompt, named so a layer above can stack beside it or replace it.
253
+ - `parseSpec` — Reads a document, dropping what it cannot use and refusing only what it cannot identify.
254
+ - `RESOLVED_DEFAULTS` — What a field falls back to when no layer said anything at all.
255
+ - `ResolvedAgent` (type) — What layered documents resolve to: the flat shape the loop already takes, plus the parts of an agent that are the host's to act on.
256
+ - `ResolvedTask` (type) — One side task, resolved: a model, and the endpoint it is reached through.
257
+ - `RetrySpec` (type) — What to do about a request lost before the model produced anything.
258
+ - `resolveAgentSpec` — Layers documents into the flat object the loop takes, weakest first.
259
+ - `SPEC_EVENTS` — Every event a hook may be bound to, restated so this module stays free of `node:crypto`.
260
+ - `SpecBundle` (type) — What an agent carries so it can travel whole: the definitions of the servers it names.
261
+ - `SpecServer` (type) — A bundled MCP server, validated only as far as a round trip needs and otherwise passed through.
262
+ - `TaskSpec` (type) — One side task, which may run on a different model — and a different machine — than the agent.
263
+ - `ToolsSpec` (type) — How tools reach the model, and which servers this agent may reach at all.
264
+
237
265
  ### stream
238
266
 
239
267
  Reading one streamed turn back into a message.
@@ -272,7 +300,7 @@ Reading what a model meant by a tool call when it did not write one cleanly.
272
300
 
273
301
  ### tool-loading
274
302
 
275
- - `carryOver` — The tools to start the next turn with: recently used, newest last, capped.
303
+ - `carryOver` — The tools to start the next turn with: last turn's where they were, then what this turn used.
276
304
  - `catalogList` — The catalogue as a plain grouped listing of names, loaded ones marked if asked.
277
305
  - `catalogPrompt` — The catalogue block appended to the system prompt.
278
306
  - `expandNames` — Resolves requested names against the catalogue, expanding trailing `*` wildcards.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.17.0",
3
+ "version": "2.18.1",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",
@@ -31,6 +31,10 @@
31
31
  ".": {
32
32
  "types": "./dist/index.d.ts",
33
33
  "import": "./dist/index.js"
34
+ },
35
+ "./spec": {
36
+ "types": "./dist/spec.d.ts",
37
+ "import": "./dist/spec.js"
34
38
  }
35
39
  },
36
40
  "files": [