@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 CHANGED
@@ -21,7 +21,7 @@ only, Node >=22.
21
21
  | Module | What it does |
22
22
  | --- | --- |
23
23
  | `schema-compat` | Makes an MCP tool schema something a strict or grammar-constrained server will accept. `sanitizeTools`, `relaxTools`, `isGrammarError`. |
24
- | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. |
24
+ | `tool-loading` | On-demand tool discovery: a name-only catalogue plus a `load_tools` meta-tool, so a run pays for the schemas it asks for instead of all of them. Plus `preselectByKeywords`, which picks from it without a model. |
25
25
  | `stream` | Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into `EndpointSilent`. |
26
26
  | `capabilities` | What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. `capabilitiesFor`, `modelCapabilitiesFor`, `negotiate`. |
27
27
  | `thinking` | Tells a scratchpad fenced inside `content` from the answer: `FenceSplitter` for a stream, `stripThinking` for a whole reply, and the fence tables both read. |
@@ -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. |
@@ -441,6 +442,33 @@ run. A preselection shapes the first step alone: those tools, no catalogue, no `
441
442
  a model with the menu still in front of it shops, reloading what it has or picking a sibling —
442
443
  and everything is back from the second step on.
443
444
 
445
+ That preselection costs a round trip to a model, which on a local box is a few seconds before the
446
+ run has started, spent on a model doing term matching. `preselect(..., { keywords: true })` does
447
+ the matching directly and spends the model only on what the words cannot settle:
448
+
449
+ ```ts
450
+ const preselected = await preselect(config, config.toolSelectModel, catalog, prompt, {
451
+ keywords: true, // or { minScore, dropoff } to move the bar
452
+ onNotice,
453
+ });
454
+ ```
455
+
456
+ `preselectByKeywords` is the matcher on its own, ranking every tool against the request by BM25
457
+ over its name, its server's label and its one-line description. BM25 rather than counting shared
458
+ words, because a catalogue is full of words every tool uses — "list", "get", "file" — and an
459
+ overlap count hands the top of the ranking to whichever tool has the longest description. English
460
+ function words are dropped outright: the inverse document frequency is meant to handle them, and
461
+ over a real corpus it would, but twenty one-line descriptions are few enough that "for" lands in
462
+ one of them and scores as the most distinctive word in the request.
463
+
464
+ What a caller acts on is `confident`, which is deliberately hard to earn: something more
465
+ distinctive than a word the catalogue shares has to have matched, and the tools the cap left out
466
+ have to score well below the ones it kept — a hit just under the line scoring nearly as much as
467
+ one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
468
+ on. Matching nothing is not confident either, since the words cannot tell a request that needs no
469
+ tools from one whose words are not in the catalogue. An empty `toolSelectModel` still means no
470
+ preselection at all, words included.
471
+
444
472
  A turn cut off at `maxTokens` is said so as a notice, and with `maxContinuations` above zero it is
445
473
  continued first. `continueTurn` is the same thing for a caller with its own loop:
446
474
 
@@ -795,6 +823,57 @@ The seam is not finished. `timeoutMs` narrows to the one field it reads, but `ge
795
823
  asks for the whole of `Endpoint`. `requestTimeoutSeconds` became optional in v2, so a consumer
796
824
  with no timeout to give now leaves it out rather than inventing a `0`.
797
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
+
798
877
  ## What is kept for the life of the process
799
878
 
800
879
  Four caches outlive any one run: the `OpenAI` clients, the model listings, the latched
@@ -5,7 +5,7 @@ import type { Endpoint, ModelParams, RetryPolicy, ToolPolicy } from "./config.ts
5
5
  import { type RunEventInput, type RunMetrics } from "./events.ts";
6
6
  import { type HookContext, type HookEvent, type HookNote, type HookRunner } from "./hooks.ts";
7
7
  import type { Turn, TurnUsage } from "./stream.ts";
8
- import { type ToolOrder } from "./tool-loading.ts";
8
+ import { type KeywordPreselectOptions, type ToolOrder } from "./tool-loading.ts";
9
9
  /**
10
10
  * The one place a streamed request's body is decided from a config and what the endpoint and
11
11
  * the model have refused.
@@ -72,19 +72,30 @@ export declare function resolveApiKey(own: {
72
72
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
73
73
  * since a side task is never worth failing the run. A stop still throws.
74
74
  *
75
+ * With `keywords`, the request's own words are matched against the catalogue first and the model
76
+ * is spent only on what they cannot settle, which on a local box is the difference between a run
77
+ * starting now and starting in a few seconds. The words have to be clear about it; see
78
+ * `preselectByKeywords` for what that means.
79
+ *
75
80
  * @param config The endpoint the preselector is reached through.
76
81
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
77
82
  * means by empty.
78
83
  * @param catalog The servers to choose from.
79
84
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
80
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
81
- * (`MAX_PER_LOAD`).
85
+ * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
86
+ * (`MAX_PER_LOAD`), and whether to try the words first.
82
87
  */
83
- export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, }?: {
88
+ export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, maxPerLoad, keywords, }?: {
84
89
  signal?: AbortSignal;
85
90
  onNotice?: (message: string) => void;
86
91
  maxTokens?: number;
87
92
  maxPerLoad?: number;
93
+ /**
94
+ * Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
95
+ * takes its defaults; an object tunes the thresholds. An empty `model` still means no
96
+ * preselection at all, words included — that is what `toolSelectModel: ""` asks for.
97
+ */
98
+ keywords?: boolean | KeywordPreselectOptions;
88
99
  }): Promise<string[]>;
89
100
  /** One call the model made, as `dispatch` is handed it. */
90
101
  export interface ToolCallRequest {
@@ -10,7 +10,7 @@ import { runTurn } from "./run-turn.js";
10
10
  import { relaxTools, sanitizeTools } from "./schema-compat.js";
11
11
  import { askJson, tryAsk } from "./side-task.js";
12
12
  import { parseToolArguments, recoverToolCalls } from "./tool-calls.js";
13
- import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
13
+ import { catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
14
14
  /**
15
15
  * The loop above a turn: send, run the tools the model asked for, send again, until it stops
16
16
  * asking.
@@ -114,17 +114,32 @@ export function resolveApiKey(own, inherited, env = process.env) {
114
114
  * a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
115
115
  * since a side task is never worth failing the run. A stop still throws.
116
116
  *
117
+ * With `keywords`, the request's own words are matched against the catalogue first and the model
118
+ * is spent only on what they cannot settle, which on a local box is the difference between a run
119
+ * starting now and starting in a few seconds. The words have to be clear about it; see
120
+ * `preselectByKeywords` for what that means.
121
+ *
117
122
  * @param config The endpoint the preselector is reached through.
118
123
  * @param model The preselector. An empty name picks nothing, which is what `toolSelectModel`
119
124
  * means by empty.
120
125
  * @param catalog The servers to choose from.
121
126
  * @param prompt The request being planned for. Only its head is read; see `preselectInput`.
122
- * @param options Cancellation, notices, the reply ceiling (256) and the cap the choice is held to
123
- * (`MAX_PER_LOAD`).
127
+ * @param options Cancellation, notices, the reply ceiling (256), the cap the choice is held to
128
+ * (`MAX_PER_LOAD`), and whether to try the words first.
124
129
  */
125
- export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, } = {}) {
130
+ export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
126
131
  if (!model || !catalog.some((server) => server.tools.length > 0))
127
132
  return [];
133
+ if (keywords) {
134
+ const guess = preselectByKeywords(catalog, prompt, {
135
+ maxPerLoad,
136
+ ...(keywords === true ? {} : keywords),
137
+ });
138
+ if (guess.confident) {
139
+ onNotice?.(`chose ${guess.names.length} tool${guess.names.length === 1 ? "" : "s"} by name`);
140
+ return guess.names;
141
+ }
142
+ }
128
143
  const reply = await tryAsk("preselect", () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, { name: "preselection", maxTokens, signal, onNotice }), { onNotice });
129
144
  return preselection(reply, catalog, maxPerLoad);
130
145
  }
package/dist/index.d.ts CHANGED
@@ -26,8 +26,9 @@ 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";
32
33
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, type ToolCall, } from "./tool-calls.ts";
33
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, type ToolOrder, } from "./tool-loading.ts";
34
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, type KeywordPreselection, type KeywordPreselectOptions, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, type ToolMatch, type ToolOrder, } from "./tool-loading.ts";
package/dist/index.js CHANGED
@@ -24,8 +24,9 @@ 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";
30
31
  export { parseToolArguments, recoverToolCalls, ToolArgumentsError, } from "./tool-calls.js";
31
- export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.js";
32
+ export { carryOver, catalogList, catalogPrompt, expandNames, inCatalog, KEYWORD_DROPOFF, KEYWORD_MIN_SCORE, LOAD_TOOLS, LOAD_TOOLS_DEFINITION, loadedTools, loadResult, MAX_CARRIED, MAX_PER_LOAD, orderTools, PRESELECT_SCHEMA, PRESELECT_SYSTEM, preselectByKeywords, preselectInput, preselection, preselectSystem, requestedNames, } from "./tool-loading.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;