@cubicecho/agent-core 2.16.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -1
- package/dist/agent-loop.d.ts +15 -4
- package/dist/agent-loop.js +19 -4
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/spec.d.ts +319 -0
- package/dist/spec.js +662 -0
- package/dist/tool-loading.d.ts +73 -0
- package/dist/tool-loading.js +125 -0
- package/llms.txt +34 -0
- package/package.json +5 -1
package/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
|
package/dist/agent-loop.d.ts
CHANGED
|
@@ -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)
|
|
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 {
|
package/dist/agent-loop.js
CHANGED
|
@@ -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)
|
|
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;
|