@cubicecho/agent-core 2.24.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/dist/agent-loop.d.ts +84 -205
- package/dist/agent-loop.js +288 -597
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -23
- package/dist/capabilities.d.ts +111 -42
- package/dist/capabilities.js +281 -138
- package/dist/client.d.ts +104 -35
- package/dist/client.js +205 -74
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +160 -86
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +24 -16
- package/dist/continuation.js +70 -44
- package/dist/digest.d.ts +7 -0
- package/dist/digest.js +18 -0
- package/dist/errors.d.ts +24 -15
- package/dist/errors.js +23 -15
- package/dist/events.d.ts +100 -63
- package/dist/events.js +250 -120
- package/dist/guards.d.ts +35 -4
- package/dist/guards.js +38 -5
- package/dist/hook-events.d.ts +21 -5
- package/dist/hook-events.js +23 -9
- package/dist/hooks.d.ts +96 -73
- package/dist/hooks.js +202 -116
- package/dist/index.d.ts +30 -26
- package/dist/index.js +29 -24
- package/dist/ledger.d.ts +62 -30
- package/dist/ledger.js +163 -56
- package/dist/platform.d.ts +13 -0
- package/dist/platform.js +13 -0
- package/dist/preselect.d.ts +222 -0
- package/dist/preselect.js +290 -0
- package/dist/request-body.d.ts +32 -0
- package/dist/request-body.js +47 -0
- package/dist/reset.d.ts +1 -0
- package/dist/reset.js +7 -6
- package/dist/retry.d.ts +29 -138
- package/dist/retry.js +69 -282
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +36 -15
- package/dist/run-turn.js +74 -37
- package/dist/runtime.d.ts +23 -17
- package/dist/runtime.js +34 -24
- package/dist/schema-compat.d.ts +35 -18
- package/dist/schema-compat.js +340 -175
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +123 -40
- package/dist/side-task.js +181 -63
- package/dist/snapshot.d.ts +10 -5
- package/dist/snapshot.js +73 -30
- package/dist/spec.d.ts +56 -25
- package/dist/spec.js +494 -215
- package/dist/stream.d.ts +66 -16
- package/dist/stream.js +220 -97
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +92 -44
- package/dist/tokens.d.ts +164 -6
- package/dist/tokens.js +319 -6
- package/dist/tool-calls.d.ts +82 -17
- package/dist/tool-calls.js +198 -83
- package/dist/tool-loading.d.ts +101 -189
- package/dist/tool-loading.js +277 -371
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +76 -39
- package/package.json +4 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { OnNotice } from './capabilities.ts';
|
|
2
|
+
import type { CatalogServer } from './catalog.ts';
|
|
3
|
+
import type { Endpoint } from './config.ts';
|
|
4
|
+
/**
|
|
5
|
+
* The system prompt a preselector is given, holding it to the cap its answer will be held to.
|
|
6
|
+
*
|
|
7
|
+
* @param [maxPerLoad] - The most to ask for. Give `preselection` the
|
|
8
|
+
* same number: this one is what the preselector is told, and that one is what it is held to.
|
|
9
|
+
* @returns The prompt, with the cap written into it.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
|
|
13
|
+
* calls `load_tools`, and only then can do the work. A small model reading the same catalogue
|
|
14
|
+
* usually names the right tools outright, so the task model finds them already loaded and
|
|
15
|
+
* starts working on its first step.
|
|
16
|
+
*
|
|
17
|
+
* A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
|
|
18
|
+
* broad guess is not, so the same cap applies here as to a `load_tools` call.
|
|
19
|
+
*/
|
|
20
|
+
export declare const preselectSystem: (maxPerLoad?: number) => string;
|
|
21
|
+
/**
|
|
22
|
+
* The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* An object around the array rather than the array, because a structured answer's root has to be
|
|
26
|
+
* an object — OpenAI's strict mode and every tool-schema normaliser insist.
|
|
27
|
+
*/
|
|
28
|
+
export declare const PRESELECT_SCHEMA: {
|
|
29
|
+
type: "object";
|
|
30
|
+
properties: {
|
|
31
|
+
tools: {
|
|
32
|
+
type: "array";
|
|
33
|
+
items: {
|
|
34
|
+
type: "string";
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
required: string[];
|
|
39
|
+
additionalProperties: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** The preselection system prompt at the default cap, for a caller that never changes it. */
|
|
42
|
+
export declare const PRESELECT_SYSTEM: string;
|
|
43
|
+
/**
|
|
44
|
+
* The user message for a preselection call: the catalogue, then the request.
|
|
45
|
+
*
|
|
46
|
+
* @param catalog - The connected servers, rendered as the name-only listing.
|
|
47
|
+
* @param prompt - The request being planned for, truncated — choosing tools needs the shape of the
|
|
48
|
+
* ask, not all of it.
|
|
49
|
+
* @param [maxPromptChars] - Where the request is cut. A caller whose requests
|
|
50
|
+
* carry the part that names the work at the end wants a larger one, and pays for it in the
|
|
51
|
+
* preselector's prompt.
|
|
52
|
+
* @returns The message text: the listing under a `# Tool catalogue` heading, then the cut request
|
|
53
|
+
* under `# Request`.
|
|
54
|
+
*/
|
|
55
|
+
export declare const preselectInput: (catalog: CatalogServer[], prompt: string, maxPromptChars?: number) => string;
|
|
56
|
+
/**
|
|
57
|
+
* Resolves a preselection against the catalogue: unknown names dropped, count capped.
|
|
58
|
+
*
|
|
59
|
+
* @param names - What the preselector replied: `{ tools: [...] }` as `PRESELECT_SCHEMA` has it, or
|
|
60
|
+
* the bare array an older prompt asked for. Unvalidated: anything else gives none, and entries
|
|
61
|
+
* that are not strings are dropped.
|
|
62
|
+
* @param catalog - The servers to resolve against.
|
|
63
|
+
* @param [maxPerLoad] - The most to keep. The same number
|
|
64
|
+
* `preselectSystem` was given, or the model is being held to a cap it was never told about.
|
|
65
|
+
* @returns Names as the catalogue spells them, each once, in the order they resolved, and never
|
|
66
|
+
* more than `maxPerLoad`. Empty where nothing resolved.
|
|
67
|
+
*/
|
|
68
|
+
export declare function preselection(names: unknown, catalog: CatalogServer[], maxPerLoad?: number): string[];
|
|
69
|
+
/**
|
|
70
|
+
* The least a best match may score and still be acted on without a model.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* A BM25 score, so it is read against the shape of the corpus rather than as a percentage: a
|
|
74
|
+
* query term carried by half the catalogue is worth about 0.7, and one carried by a single tool
|
|
75
|
+
* about 3. One at this floor is therefore "something more distinctive than a word every other
|
|
76
|
+
* tool uses", which is the weakest evidence worth skipping a round trip on.
|
|
77
|
+
*
|
|
78
|
+
* A term is distinctive only against other terms, so a catalogue of three or four tools rarely
|
|
79
|
+
* clears it. That is the right answer rather than a gap: a catalogue that small is not costing
|
|
80
|
+
* enough tokens to be worth choosing from in the first place.
|
|
81
|
+
*/
|
|
82
|
+
export declare const KEYWORD_MIN_SCORE = 1;
|
|
83
|
+
/**
|
|
84
|
+
* How far the best unpicked tool must fall below the last picked one for the cut to count clean.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Half. The cap is the only reason a hit is dropped, so a hit just underneath it scoring nearly
|
|
88
|
+
* as much as one just above means the ranking chose arbitrarily, which is exactly the case a
|
|
89
|
+
* model should be spent on.
|
|
90
|
+
*/
|
|
91
|
+
export declare const KEYWORD_DROPOFF = 0.5;
|
|
92
|
+
/** One tool's score against a request. */
|
|
93
|
+
export interface ToolMatch {
|
|
94
|
+
name: string;
|
|
95
|
+
/** Its BM25 score. Zero-scoring tools are not ranked at all. */
|
|
96
|
+
score: number;
|
|
97
|
+
}
|
|
98
|
+
/** What `preselectByKeywords` found. */
|
|
99
|
+
export interface KeywordPreselection {
|
|
100
|
+
/** The names, best first, capped at `maxPerLoad`. */
|
|
101
|
+
names: string[];
|
|
102
|
+
/** Whether the match is clear enough to run on without asking a model. */
|
|
103
|
+
confident: boolean;
|
|
104
|
+
/** Every tool that scored at all, best first — for a caller measuring its own threshold. */
|
|
105
|
+
ranked: ToolMatch[];
|
|
106
|
+
}
|
|
107
|
+
/** What `preselectByKeywords` takes besides the catalogue and the request. */
|
|
108
|
+
export interface KeywordPreselectOptions {
|
|
109
|
+
/**
|
|
110
|
+
* The most to pick. The same cap the model is held to.
|
|
111
|
+
*
|
|
112
|
+
* @defaultValue `MAX_PER_LOAD`
|
|
113
|
+
*/
|
|
114
|
+
maxPerLoad?: number;
|
|
115
|
+
/**
|
|
116
|
+
* The floor under a confident best match.
|
|
117
|
+
*
|
|
118
|
+
* @defaultValue `KEYWORD_MIN_SCORE`
|
|
119
|
+
*/
|
|
120
|
+
minScore?: number;
|
|
121
|
+
/**
|
|
122
|
+
* The gap a confident cut needs.
|
|
123
|
+
*
|
|
124
|
+
* @defaultValue `KEYWORD_DROPOFF`
|
|
125
|
+
*/
|
|
126
|
+
dropoff?: number;
|
|
127
|
+
/**
|
|
128
|
+
* Where the request is cut — the same head `preselectInput` reads.
|
|
129
|
+
*
|
|
130
|
+
* @defaultValue `2000`
|
|
131
|
+
*/
|
|
132
|
+
maxPromptChars?: number;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The tools a request's own words point at, ranked, and whether they point clearly enough.
|
|
136
|
+
*
|
|
137
|
+
* @param catalog - The servers to choose from. Each tool is matched on its name, its server's label
|
|
138
|
+
* and its one-line description, which is everything the catalogue holds.
|
|
139
|
+
* @param prompt - The request being planned for. Only its head is read, as in `preselectInput`.
|
|
140
|
+
* @param [options] - The cap, the two confidence thresholds, and where the request is cut.
|
|
141
|
+
* @returns The ranking, the names picked from the top of it, and whether to act on them. No names
|
|
142
|
+
* and not confident where the catalogue has no tools, the request has no word left to match on,
|
|
143
|
+
* or nothing scored.
|
|
144
|
+
*
|
|
145
|
+
* @remarks
|
|
146
|
+
* A preselection call costs a round trip to a model that is being asked to do term matching, and
|
|
147
|
+
* on a local box that is seconds before the run has started. For a catalogue of a few dozen tools
|
|
148
|
+
* the words usually decide it: a request that says "commit" and a tool called `git__commit` need
|
|
149
|
+
* no reasoning to connect.
|
|
150
|
+
*
|
|
151
|
+
* BM25 rather than counting shared words, because the ranking has to survive the words every tool
|
|
152
|
+
* uses. "list", "get" and "file" are in half the descriptions in a real catalogue, and a plain
|
|
153
|
+
* overlap count hands the top of the ranking to whichever tool has the longest description. The
|
|
154
|
+
* inverse document frequency makes a term worth what it distinguishes, and the length
|
|
155
|
+
* normalisation stops a wordy description from outscoring the tool actually named.
|
|
156
|
+
*
|
|
157
|
+
* `confident` is what a caller acts on, and it is deliberately hard to earn: something more
|
|
158
|
+
* distinctive than a word the whole catalogue shares has to have matched, and the tools left
|
|
159
|
+
* unpicked have to score well below the ones picked. Anything else is ambiguous, and ambiguous is
|
|
160
|
+
* what the model is for. Nothing matching is not confident either — the words cannot tell "this
|
|
161
|
+
* request needs no tools" from "these words are not in the catalogue".
|
|
162
|
+
*/
|
|
163
|
+
export declare function preselectByKeywords(catalog: CatalogServer[], prompt: string, { maxPerLoad, minScore, dropoff, maxPromptChars, }?: KeywordPreselectOptions): KeywordPreselection;
|
|
164
|
+
/** What `preselect` may be told beyond the question: how to stop it, and how to ask. */
|
|
165
|
+
export interface PreselectOptions {
|
|
166
|
+
/** Abandons the side task. */
|
|
167
|
+
signal?: AbortSignal;
|
|
168
|
+
/** Hears how the choice was made, and what was given up on along the way. */
|
|
169
|
+
onNotice?: OnNotice;
|
|
170
|
+
/**
|
|
171
|
+
* The reply's ceiling.
|
|
172
|
+
*
|
|
173
|
+
* @defaultValue `256`
|
|
174
|
+
*/
|
|
175
|
+
maxTokens?: number;
|
|
176
|
+
/**
|
|
177
|
+
* As `SideTaskOptions.temperature`.
|
|
178
|
+
*
|
|
179
|
+
* @defaultValue `0.3`
|
|
180
|
+
*/
|
|
181
|
+
temperature?: number;
|
|
182
|
+
/** As `SideTaskOptions.reasoningEffort`: absent keeps the no-thinking hints. */
|
|
183
|
+
reasoningEffort?: string;
|
|
184
|
+
/**
|
|
185
|
+
* The most tools the choice may name.
|
|
186
|
+
*
|
|
187
|
+
* @defaultValue `MAX_PER_LOAD`
|
|
188
|
+
*/
|
|
189
|
+
maxPerLoad?: number;
|
|
190
|
+
/**
|
|
191
|
+
* Try `preselectByKeywords` first and spend the model only on what it cannot settle. `true`
|
|
192
|
+
* takes its defaults; an object tunes the thresholds. An empty `model` still means no
|
|
193
|
+
* preselection at all, words included — that is what `toolSelectModel: ""` asks for.
|
|
194
|
+
*/
|
|
195
|
+
keywords?: boolean | KeywordPreselectOptions;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
199
|
+
*
|
|
200
|
+
* @param config - The endpoint the preselector is reached through.
|
|
201
|
+
* @param model - The preselector. An empty name picks nothing, which is what `toolSelectModel`
|
|
202
|
+
* means by empty.
|
|
203
|
+
* @param catalog - The servers to choose from.
|
|
204
|
+
* @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
|
|
205
|
+
* @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
|
|
206
|
+
* as `ask` reads them, the cap the choice is held to, and whether to try the words first.
|
|
207
|
+
* @returns Names as the catalogue spells them. Empty where `model` is empty, the catalogue has no
|
|
208
|
+
* tools, the call failed, or its reply named nothing in the catalogue.
|
|
209
|
+
*
|
|
210
|
+
* @remarks
|
|
211
|
+
* On-demand loading otherwise spends a round trip on reading the catalogue and calling
|
|
212
|
+
* `load_tools`; a small model reading the same catalogue usually names the right tools, and the
|
|
213
|
+
* task model opens with them in hand. A wrong guess costs a few hundred tokens for one run, and
|
|
214
|
+
* a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
|
|
215
|
+
* since a side task is never worth failing the run. A stop still throws.
|
|
216
|
+
*
|
|
217
|
+
* With `keywords`, the request's own words are matched against the catalogue first and the model
|
|
218
|
+
* is spent only on what they cannot settle, which on a local box is the difference between a run
|
|
219
|
+
* starting now and starting in a few seconds. The words have to be clear about it; see
|
|
220
|
+
* `preselectByKeywords` for what that means.
|
|
221
|
+
*/
|
|
222
|
+
export declare function preselect(config: Endpoint, model: string, catalog: CatalogServer[], prompt: string, { signal, onNotice, maxTokens, temperature, reasoningEffort, maxPerLoad, keywords, }?: PreselectOptions): Promise<string[]>;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { counted, isRecord } from './guards.js';
|
|
2
|
+
import { askJson, tryAsk } from './side-task.js';
|
|
3
|
+
import { catalogList, expandNames, MAX_PER_LOAD } from './tool-loading.js';
|
|
4
|
+
import { SchemaType } from './wire.js';
|
|
5
|
+
/**
|
|
6
|
+
* Choosing a run's tools before it starts, from the request and the catalogue's names.
|
|
7
|
+
*
|
|
8
|
+
* On-demand loading spends the first round trip on the model reading the catalogue and asking for
|
|
9
|
+
* what it wants. A shortlist made beforehand saves that trip, and there are two ways to make one:
|
|
10
|
+
* a small model reading the same catalogue, or the request's own words scored against the tool
|
|
11
|
+
* names, which costs nothing and is trusted only when the words are clear about it.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Where a request is cut for the preselector, in characters.
|
|
15
|
+
*
|
|
16
|
+
* @remarks
|
|
17
|
+
* A tool choice is made on what the work is, which is the top of a request rather than all of
|
|
18
|
+
* it — and the whole of a long one is paid for again in the preselection call. `preselectInput`
|
|
19
|
+
* takes another number for a caller whose requests are not shaped that way.
|
|
20
|
+
*/
|
|
21
|
+
const PRESELECT_PROMPT_CHARS = 2000;
|
|
22
|
+
/**
|
|
23
|
+
* The system prompt a preselector is given, holding it to the cap its answer will be held to.
|
|
24
|
+
*
|
|
25
|
+
* @param [maxPerLoad] - The most to ask for. Give `preselection` the
|
|
26
|
+
* same number: this one is what the preselector is told, and that one is what it is held to.
|
|
27
|
+
* @returns The prompt, with the cap written into it.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
|
|
31
|
+
* calls `load_tools`, and only then can do the work. A small model reading the same catalogue
|
|
32
|
+
* usually names the right tools outright, so the task model finds them already loaded and
|
|
33
|
+
* starts working on its first step.
|
|
34
|
+
*
|
|
35
|
+
* A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
|
|
36
|
+
* broad guess is not, so the same cap applies here as to a `load_tools` call.
|
|
37
|
+
*/
|
|
38
|
+
export const preselectSystem = (maxPerLoad = MAX_PER_LOAD) => 'You choose tools. Below is a catalogue of tool names, then a request. Reply with a JSON ' +
|
|
39
|
+
'object whose "tools" array holds the names the request is likely to need — exact names from ' +
|
|
40
|
+
`the catalogue, at most ${maxPerLoad}, and as few as could do the job. Reply with ` +
|
|
41
|
+
'`{"tools": []}` if the request can be answered without tools. Reply with the object alone — ' +
|
|
42
|
+
'no prose, no explanation.';
|
|
43
|
+
/**
|
|
44
|
+
* The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* An object around the array rather than the array, because a structured answer's root has to be
|
|
48
|
+
* an object — OpenAI's strict mode and every tool-schema normaliser insist.
|
|
49
|
+
*/
|
|
50
|
+
export const PRESELECT_SCHEMA = {
|
|
51
|
+
type: SchemaType.Object,
|
|
52
|
+
properties: { tools: { type: SchemaType.Array, items: { type: SchemaType.String } } },
|
|
53
|
+
required: ['tools'],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
};
|
|
56
|
+
/** The preselection system prompt at the default cap, for a caller that never changes it. */
|
|
57
|
+
export const PRESELECT_SYSTEM = preselectSystem();
|
|
58
|
+
/**
|
|
59
|
+
* The user message for a preselection call: the catalogue, then the request.
|
|
60
|
+
*
|
|
61
|
+
* @param catalog - The connected servers, rendered as the name-only listing.
|
|
62
|
+
* @param prompt - The request being planned for, truncated — choosing tools needs the shape of the
|
|
63
|
+
* ask, not all of it.
|
|
64
|
+
* @param [maxPromptChars] - Where the request is cut. A caller whose requests
|
|
65
|
+
* carry the part that names the work at the end wants a larger one, and pays for it in the
|
|
66
|
+
* preselector's prompt.
|
|
67
|
+
* @returns The message text: the listing under a `# Tool catalogue` heading, then the cut request
|
|
68
|
+
* under `# Request`.
|
|
69
|
+
*/
|
|
70
|
+
export const preselectInput = (catalog, prompt, maxPromptChars = PRESELECT_PROMPT_CHARS) => `# Tool catalogue\n\n${catalogList(catalog)}\n\n# Request\n\n${prompt.slice(0, maxPromptChars)}`;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves a preselection against the catalogue: unknown names dropped, count capped.
|
|
73
|
+
*
|
|
74
|
+
* @param names - What the preselector replied: `{ tools: [...] }` as `PRESELECT_SCHEMA` has it, or
|
|
75
|
+
* the bare array an older prompt asked for. Unvalidated: anything else gives none, and entries
|
|
76
|
+
* that are not strings are dropped.
|
|
77
|
+
* @param catalog - The servers to resolve against.
|
|
78
|
+
* @param [maxPerLoad] - The most to keep. The same number
|
|
79
|
+
* `preselectSystem` was given, or the model is being held to a cap it was never told about.
|
|
80
|
+
* @returns Names as the catalogue spells them, each once, in the order they resolved, and never
|
|
81
|
+
* more than `maxPerLoad`. Empty where nothing resolved.
|
|
82
|
+
*/
|
|
83
|
+
export function preselection(names, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
84
|
+
const list = isRecord(names) ? names.tools : names;
|
|
85
|
+
if (!Array.isArray(list)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const wanted = list.filter((name) => typeof name === 'string');
|
|
89
|
+
return expandNames(wanted, catalog, maxPerLoad).matched.slice(0, maxPerLoad);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Saturation and length normalisation for the BM25 score. Robertson's usual values.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* Nothing here is tuned for this corpus, because tuning them against a catalogue of forty short
|
|
96
|
+
* documents would be fitting noise. `dropoff` and `minScore` are the knobs worth turning.
|
|
97
|
+
*/
|
|
98
|
+
const BM25_K1 = 1.2;
|
|
99
|
+
const BM25_B = 0.75;
|
|
100
|
+
/** What BM25 adds to both counts in its inverse document frequency, so a term every document holds still scores. */
|
|
101
|
+
const IDF_SMOOTHING = 0.5;
|
|
102
|
+
/**
|
|
103
|
+
* The least a best match may score and still be acted on without a model.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* A BM25 score, so it is read against the shape of the corpus rather than as a percentage: a
|
|
107
|
+
* query term carried by half the catalogue is worth about 0.7, and one carried by a single tool
|
|
108
|
+
* about 3. One at this floor is therefore "something more distinctive than a word every other
|
|
109
|
+
* tool uses", which is the weakest evidence worth skipping a round trip on.
|
|
110
|
+
*
|
|
111
|
+
* A term is distinctive only against other terms, so a catalogue of three or four tools rarely
|
|
112
|
+
* clears it. That is the right answer rather than a gap: a catalogue that small is not costing
|
|
113
|
+
* enough tokens to be worth choosing from in the first place.
|
|
114
|
+
*/
|
|
115
|
+
export const KEYWORD_MIN_SCORE = 1;
|
|
116
|
+
/**
|
|
117
|
+
* How far the best unpicked tool must fall below the last picked one for the cut to count clean.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* Half. The cap is the only reason a hit is dropped, so a hit just underneath it scoring nearly
|
|
121
|
+
* as much as one just above means the ranking chose arbitrarily, which is exactly the case a
|
|
122
|
+
* model should be spent on.
|
|
123
|
+
*/
|
|
124
|
+
export const KEYWORD_DROPOFF = 0.5;
|
|
125
|
+
/** The longest word `terms` leaves its trailing `s` on: `bus` and `was` are not plurals. */
|
|
126
|
+
const SHORTEST_PLURAL = 3;
|
|
127
|
+
/** What the preselector's reply schema is called on the wire. */
|
|
128
|
+
const PRESELECT_SCHEMA_NAME = 'preselection';
|
|
129
|
+
/**
|
|
130
|
+
* English function words, dropped before matching.
|
|
131
|
+
*
|
|
132
|
+
* @remarks
|
|
133
|
+
* The inverse document frequency is supposed to make this unnecessary, and over a real corpus it
|
|
134
|
+
* would: a word carried by every document is worth nothing. But a tool catalogue is twenty
|
|
135
|
+
* one-line descriptions, and at that size "for" or "on" is rare by accident — it lands in one
|
|
136
|
+
* description, scores as the most distinctive term in the query, and a request that says "for me"
|
|
137
|
+
* is answered with whichever tool happened to use the word. Only closure-class words are here;
|
|
138
|
+
* "list", "get", "run" and "show" are what tools are called and stay.
|
|
139
|
+
*/
|
|
140
|
+
const NOISE = new Set(('about all also am an and any are as at be been being but by can could do does for from had ' +
|
|
141
|
+
'has have how if in into is it its just me more most my no not of on or other our out over ' +
|
|
142
|
+
'please should so some such than that the their them then there these they this to too up us ' +
|
|
143
|
+
'very was we were what when where which who will with would you your').split(' '));
|
|
144
|
+
/**
|
|
145
|
+
* A text as the matcher reads it: lowercase words, `server__tool_name` and camelCase split apart.
|
|
146
|
+
*
|
|
147
|
+
* @param text - A request, or a tool's name, server label and description run together.
|
|
148
|
+
* @returns The words in the order they came, repeats kept. One-character words and the function
|
|
149
|
+
* words in `NOISE` are left out, and anything but an ASCII letter or digit only separates words.
|
|
150
|
+
*
|
|
151
|
+
* @remarks
|
|
152
|
+
* Plurals are folded, crudely, by dropping a trailing `s`: a request says "read the files" and
|
|
153
|
+
* the tool is called `read_file`, and without this the two do not meet. Nothing else is stemmed —
|
|
154
|
+
* a real stemmer is a table of English morphology, and this is matching identifiers.
|
|
155
|
+
*/
|
|
156
|
+
const terms = (text) => text
|
|
157
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
158
|
+
.toLowerCase()
|
|
159
|
+
.split(/[^a-z0-9]+/)
|
|
160
|
+
.filter((word) => word.length > 1 && !NOISE.has(word))
|
|
161
|
+
.map((word) => word.length > SHORTEST_PLURAL && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word);
|
|
162
|
+
/**
|
|
163
|
+
* The tools a request's own words point at, ranked, and whether they point clearly enough.
|
|
164
|
+
*
|
|
165
|
+
* @param catalog - The servers to choose from. Each tool is matched on its name, its server's label
|
|
166
|
+
* and its one-line description, which is everything the catalogue holds.
|
|
167
|
+
* @param prompt - The request being planned for. Only its head is read, as in `preselectInput`.
|
|
168
|
+
* @param [options] - The cap, the two confidence thresholds, and where the request is cut.
|
|
169
|
+
* @returns The ranking, the names picked from the top of it, and whether to act on them. No names
|
|
170
|
+
* and not confident where the catalogue has no tools, the request has no word left to match on,
|
|
171
|
+
* or nothing scored.
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* A preselection call costs a round trip to a model that is being asked to do term matching, and
|
|
175
|
+
* on a local box that is seconds before the run has started. For a catalogue of a few dozen tools
|
|
176
|
+
* the words usually decide it: a request that says "commit" and a tool called `git__commit` need
|
|
177
|
+
* no reasoning to connect.
|
|
178
|
+
*
|
|
179
|
+
* BM25 rather than counting shared words, because the ranking has to survive the words every tool
|
|
180
|
+
* uses. "list", "get" and "file" are in half the descriptions in a real catalogue, and a plain
|
|
181
|
+
* overlap count hands the top of the ranking to whichever tool has the longest description. The
|
|
182
|
+
* inverse document frequency makes a term worth what it distinguishes, and the length
|
|
183
|
+
* normalisation stops a wordy description from outscoring the tool actually named.
|
|
184
|
+
*
|
|
185
|
+
* `confident` is what a caller acts on, and it is deliberately hard to earn: something more
|
|
186
|
+
* distinctive than a word the whole catalogue shares has to have matched, and the tools left
|
|
187
|
+
* unpicked have to score well below the ones picked. Anything else is ambiguous, and ambiguous is
|
|
188
|
+
* what the model is for. Nothing matching is not confident either — the words cannot tell "this
|
|
189
|
+
* request needs no tools" from "these words are not in the catalogue".
|
|
190
|
+
*/
|
|
191
|
+
export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD, minScore = KEYWORD_MIN_SCORE, dropoff = KEYWORD_DROPOFF, maxPromptChars = PRESELECT_PROMPT_CHARS, } = {}) {
|
|
192
|
+
const empty = { names: [], confident: false, ranked: [] };
|
|
193
|
+
const docs = catalog.flatMap((server) => server.tools.map((tool) => ({
|
|
194
|
+
name: tool.name,
|
|
195
|
+
terms: terms(`${tool.name} ${server.label} ${tool.description}`),
|
|
196
|
+
})));
|
|
197
|
+
// A query term repeated in the request is not worth more than one said once: the request is
|
|
198
|
+
// prose about a task, not a document being matched against another document.
|
|
199
|
+
const query = new Set(terms(prompt.slice(0, maxPromptChars)));
|
|
200
|
+
if (!docs.length || !query.size) {
|
|
201
|
+
return empty;
|
|
202
|
+
}
|
|
203
|
+
const length = docs.reduce((total, doc) => total + doc.terms.length, 0) / docs.length;
|
|
204
|
+
const documents = new Map();
|
|
205
|
+
for (const doc of docs) {
|
|
206
|
+
for (const term of new Set(doc.terms)) {
|
|
207
|
+
documents.set(term, (documents.get(term) ?? 0) + 1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const ranked = [];
|
|
211
|
+
for (const doc of docs) {
|
|
212
|
+
const counts = new Map();
|
|
213
|
+
for (const term of doc.terms) {
|
|
214
|
+
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
215
|
+
}
|
|
216
|
+
let score = 0;
|
|
217
|
+
for (const term of query) {
|
|
218
|
+
const found = counts.get(term);
|
|
219
|
+
if (!found) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const held = documents.get(term) ?? 0;
|
|
223
|
+
const idf = Math.log(1 + (docs.length - held + IDF_SMOOTHING) / (held + IDF_SMOOTHING));
|
|
224
|
+
const norm = BM25_K1 * (1 - BM25_B + (BM25_B * doc.terms.length) / length);
|
|
225
|
+
score += (idf * found * (BM25_K1 + 1)) / (found + norm);
|
|
226
|
+
}
|
|
227
|
+
if (score > 0) {
|
|
228
|
+
ranked.push({ name: doc.name, score });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Ties break on the name, not on where the tool sat in the catalogue, so reconnecting a
|
|
232
|
+
// server in a different order does not change what a run opens with.
|
|
233
|
+
ranked.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
234
|
+
if (!ranked.length) {
|
|
235
|
+
return empty;
|
|
236
|
+
}
|
|
237
|
+
const names = ranked.slice(0, maxPerLoad).map((hit) => hit.name);
|
|
238
|
+
const cut = ranked[names.length - 1].score;
|
|
239
|
+
const next = ranked[maxPerLoad]?.score ?? 0;
|
|
240
|
+
return { names, confident: ranked[0].score >= minScore && next <= dropoff * cut, ranked };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The tools a request is likely to need, picked by a small model before the run starts, or none.
|
|
244
|
+
*
|
|
245
|
+
* @param config - The endpoint the preselector is reached through.
|
|
246
|
+
* @param model - The preselector. An empty name picks nothing, which is what `toolSelectModel`
|
|
247
|
+
* means by empty.
|
|
248
|
+
* @param catalog - The servers to choose from.
|
|
249
|
+
* @param prompt - The request being planned for. Only its head is read; see `preselectInput`.
|
|
250
|
+
* @param [options] - Cancellation, notices, the reply ceiling, the temperature and reasoning effort
|
|
251
|
+
* as `ask` reads them, the cap the choice is held to, and whether to try the words first.
|
|
252
|
+
* @returns Names as the catalogue spells them. Empty where `model` is empty, the catalogue has no
|
|
253
|
+
* tools, the call failed, or its reply named nothing in the catalogue.
|
|
254
|
+
*
|
|
255
|
+
* @remarks
|
|
256
|
+
* On-demand loading otherwise spends a round trip on reading the catalogue and calling
|
|
257
|
+
* `load_tools`; a small model reading the same catalogue usually names the right tools, and the
|
|
258
|
+
* task model opens with them in hand. A wrong guess costs a few hundred tokens for one run, and
|
|
259
|
+
* a failed one costs nothing — it is reported through `onNotice` and answered with an empty list,
|
|
260
|
+
* since a side task is never worth failing the run. A stop still throws.
|
|
261
|
+
*
|
|
262
|
+
* With `keywords`, the request's own words are matched against the catalogue first and the model
|
|
263
|
+
* is spent only on what they cannot settle, which on a local box is the difference between a run
|
|
264
|
+
* starting now and starting in a few seconds. The words have to be clear about it; see
|
|
265
|
+
* `preselectByKeywords` for what that means.
|
|
266
|
+
*/
|
|
267
|
+
export async function preselect(config, model, catalog, prompt, { signal, onNotice, maxTokens = 256, temperature, reasoningEffort, maxPerLoad = MAX_PER_LOAD, keywords, } = {}) {
|
|
268
|
+
if (!model || !catalog.some((server) => server.tools.length > 0)) {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
if (keywords) {
|
|
272
|
+
const guess = preselectByKeywords(catalog, prompt, {
|
|
273
|
+
maxPerLoad,
|
|
274
|
+
...(keywords === true ? {} : keywords),
|
|
275
|
+
});
|
|
276
|
+
if (guess.confident) {
|
|
277
|
+
onNotice?.(`chose ${counted(guess.names.length, 'tool')} by name`);
|
|
278
|
+
return guess.names;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const reply = await tryAsk('preselect', () => askJson(config, model, preselectSystem(maxPerLoad), preselectInput(catalog, prompt), PRESELECT_SCHEMA, {
|
|
282
|
+
name: PRESELECT_SCHEMA_NAME,
|
|
283
|
+
maxTokens,
|
|
284
|
+
temperature,
|
|
285
|
+
reasoningEffort,
|
|
286
|
+
signal,
|
|
287
|
+
onNotice,
|
|
288
|
+
}), { onNotice });
|
|
289
|
+
return preselection(reply, catalog, maxPerLoad);
|
|
290
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type OpenAI from 'openai';
|
|
2
|
+
import { type Capabilities, type ModelCapabilities } from './capabilities.ts';
|
|
3
|
+
import { type ModelParams } from './config.ts';
|
|
4
|
+
import { type ToolOrder } from './tool-loading.ts';
|
|
5
|
+
/**
|
|
6
|
+
* The one place a streamed request's body is decided from a config and what the endpoint and
|
|
7
|
+
* the model have refused.
|
|
8
|
+
*
|
|
9
|
+
* @param config - What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
10
|
+
* absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
|
|
11
|
+
* cheapest it takes by `effortFor`.
|
|
12
|
+
* @param supports - What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
13
|
+
* @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
14
|
+
* that has refused nothing.
|
|
15
|
+
* @param messages - The request's messages, system prompt included, sent as they are.
|
|
16
|
+
* @param [tools] - The tool definitions. Ordered by name, sanitised here — a lookup for a definition
|
|
17
|
+
* seen before — and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
|
|
18
|
+
* @param [order] - How to order them before sending. `true` is by name, which keeps the cache
|
|
19
|
+
* when the caller's array is assembled differently from one request to the next. See
|
|
20
|
+
* `orderTools`.
|
|
21
|
+
* @returns A fresh body, always streaming, around the caller's own `messages` array. `model`,
|
|
22
|
+
* `messages`, `stream` and `tools` are never `extraBody`'s to set.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Every field that negotiates lives here: the ceiling's two spellings, a temperature only a
|
|
26
|
+
* model that takes ours is sent, a reasoning effort only one that takes it is, `stream_options`
|
|
27
|
+
* only where the server has heard of it, relaxed schemas only where it could not build a grammar,
|
|
28
|
+
* and `extraBody` last, less whatever the model refused by name. The ceiling is tested
|
|
29
|
+
* `=== false` — `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true` and an absent
|
|
30
|
+
* one has to read the same — which is the test one of the three copies had inverted.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildBody(config: ModelParams, supports: Capabilities, refused: ModelCapabilities | undefined, messages: OpenAI.ChatCompletionMessageParam[], tools?: OpenAI.ChatCompletionTool[], order?: ToolOrder): OpenAI.ChatCompletionCreateParamsStreaming;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ceilingAndTemperature, effortFor } from './capabilities.js';
|
|
2
|
+
import { RESERVED_BODY_FIELDS } from './config.js';
|
|
3
|
+
import { relaxTools, sanitizeTools } from './schema-compat.js';
|
|
4
|
+
import { orderTools } from './tool-loading.js';
|
|
5
|
+
/**
|
|
6
|
+
* The one place a streamed request's body is decided from a config and what the endpoint and
|
|
7
|
+
* the model have refused.
|
|
8
|
+
*
|
|
9
|
+
* @param config - What to ask for. `maxTokens` of zero or less sends no ceiling; `reasoningEffort`
|
|
10
|
+
* absent or `"off"` sends no effort, and one the model has refused by value is stepped up to the
|
|
11
|
+
* cheapest it takes by `effortFor`.
|
|
12
|
+
* @param supports - What the endpoint has refused, as `negotiate` hands it to `send`.
|
|
13
|
+
* @param refused - What the model has refused, as `negotiate` hands it over. Absent is a model
|
|
14
|
+
* that has refused nothing.
|
|
15
|
+
* @param messages - The request's messages, system prompt included, sent as they are.
|
|
16
|
+
* @param [tools] - The tool definitions. Ordered by name, sanitised here — a lookup for a definition
|
|
17
|
+
* seen before — and relaxed where the endpoint needs it. Empty sends no `tools` field at all.
|
|
18
|
+
* @param [order] - How to order them before sending. `true` is by name, which keeps the cache
|
|
19
|
+
* when the caller's array is assembled differently from one request to the next. See
|
|
20
|
+
* `orderTools`.
|
|
21
|
+
* @returns A fresh body, always streaming, around the caller's own `messages` array. `model`,
|
|
22
|
+
* `messages`, `stream` and `tools` are never `extraBody`'s to set.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Every field that negotiates lives here: the ceiling's two spellings, a temperature only a
|
|
26
|
+
* model that takes ours is sent, a reasoning effort only one that takes it is, `stream_options`
|
|
27
|
+
* only where the server has heard of it, relaxed schemas only where it could not build a grammar,
|
|
28
|
+
* and `extraBody` last, less whatever the model refused by name. The ceiling is tested
|
|
29
|
+
* `=== false` — `modelCapabilitiesFor` starts a model at `legacyTokenLimit: true` and an absent
|
|
30
|
+
* one has to read the same — which is the test one of the three copies had inverted.
|
|
31
|
+
*/
|
|
32
|
+
export function buildBody(config, supports, refused, messages, tools = [], order = true) {
|
|
33
|
+
const sorted = orderTools(tools, order);
|
|
34
|
+
const declared = supports.strictSchemas ? sanitizeTools(sorted) : relaxTools(sanitizeTools(sorted));
|
|
35
|
+
const effort = effortFor(refused, config.reasoningEffort);
|
|
36
|
+
const extra = Object.entries(config.extraBody ?? {}).filter(([field]) => !RESERVED_BODY_FIELDS.includes(field) && !refused?.refusedFields.has(field));
|
|
37
|
+
return {
|
|
38
|
+
...ceilingAndTemperature(refused, config.maxTokens, config.temperature),
|
|
39
|
+
...(effort ? { reasoning_effort: effort } : {}),
|
|
40
|
+
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
|
|
41
|
+
...Object.fromEntries(extra),
|
|
42
|
+
model: config.model,
|
|
43
|
+
messages,
|
|
44
|
+
stream: true,
|
|
45
|
+
...(declared.length ? { tools: declared } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/reset.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Forgets everything this package remembers between calls.
|
|
3
3
|
*
|
|
4
|
+
* @remarks
|
|
4
5
|
* Six modules here keep state for the life of the runtime — the process's, unless this is called
|
|
5
6
|
* as a method of one `createRuntime` made, which clears that one alone — each for a good reason
|
|
6
7
|
* and each with its own seam: the pooled clients and their model listings, the endpoints that turned
|
package/dist/reset.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { resetCalibration } from
|
|
2
|
-
import { resetCapabilities } from
|
|
3
|
-
import { resetClients } from
|
|
4
|
-
import { resetEvents } from
|
|
5
|
-
import { resetHooks } from
|
|
6
|
-
import { resetHints } from
|
|
1
|
+
import { resetCalibration } from './calibration.js';
|
|
2
|
+
import { resetCapabilities } from './capabilities.js';
|
|
3
|
+
import { resetClients } from './client.js';
|
|
4
|
+
import { resetEvents } from './events.js';
|
|
5
|
+
import { resetHooks } from './hooks.js';
|
|
6
|
+
import { resetHints } from './side-task.js';
|
|
7
7
|
/**
|
|
8
8
|
* Forgets everything this package remembers between calls.
|
|
9
9
|
*
|
|
10
|
+
* @remarks
|
|
10
11
|
* Six modules here keep state for the life of the runtime — the process's, unless this is called
|
|
11
12
|
* as a method of one `createRuntime` made, which clears that one alone — each for a good reason
|
|
12
13
|
* and each with its own seam: the pooled clients and their model listings, the endpoints that turned
|