@cubicecho/agent-core 2.23.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 +14 -9
- package/dist/agent-loop.d.ts +84 -204
- package/dist/agent-loop.js +416 -643
- package/dist/calibration.d.ts +14 -10
- package/dist/calibration.js +30 -22
- package/dist/capabilities.d.ts +140 -35
- package/dist/capabilities.js +345 -152
- package/dist/client.d.ts +104 -35
- package/dist/client.js +210 -78
- package/dist/compaction.d.ts +103 -60
- package/dist/compaction.js +163 -93
- package/dist/config.d.ts +48 -12
- package/dist/config.js +20 -1
- package/dist/continuation.d.ts +25 -17
- package/dist/continuation.js +70 -47
- 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 +108 -58
- package/dist/events.js +265 -128
- package/dist/guards.d.ts +53 -0
- package/dist/guards.js +55 -0
- package/dist/hook-events.d.ts +38 -0
- package/dist/hook-events.js +36 -0
- package/dist/hooks.d.ts +105 -83
- package/dist/hooks.js +224 -139
- package/dist/index.d.ts +30 -25
- package/dist/index.js +29 -23
- 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 +40 -143
- package/dist/retry.js +79 -285
- package/dist/run-calls.d.ts +123 -0
- package/dist/run-calls.js +351 -0
- package/dist/run-turn.d.ts +44 -16
- 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 +51 -11
- package/dist/schema-compat.js +375 -193
- package/dist/scope.d.ts +28 -5
- package/dist/scope.js +37 -10
- package/dist/side-task.d.ts +127 -39
- package/dist/side-task.js +189 -100
- package/dist/snapshot.d.ts +12 -7
- package/dist/snapshot.js +87 -65
- package/dist/spec.d.ts +66 -36
- package/dist/spec.js +514 -265
- package/dist/stream.d.ts +73 -16
- package/dist/stream.js +228 -98
- package/dist/thinking.d.ts +23 -8
- package/dist/thinking.js +96 -46
- package/dist/tokens.d.ts +171 -6
- package/dist/tokens.js +326 -6
- package/dist/tool-calls.d.ts +89 -15
- package/dist/tool-calls.js +205 -85
- package/dist/tool-loading.d.ts +109 -187
- package/dist/tool-loading.js +286 -372
- package/dist/wire.d.ts +81 -0
- package/dist/wire.js +73 -0
- package/llms.txt +87 -43
- package/package.json +4 -4
package/dist/tool-loading.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { isRecord } from './guards.js';
|
|
2
|
+
import { ARGUMENT_PREVIEW_CHARS } from './tool-calls.js';
|
|
3
|
+
import { FUNCTION_TOOL, SchemaType } from './wire.js';
|
|
1
4
|
/**
|
|
2
5
|
* On-demand tool loading.
|
|
3
6
|
*
|
|
7
|
+
* @remarks
|
|
4
8
|
* A full tool definition is mostly JSON Schema, and it is sent on every request of every
|
|
5
9
|
* iteration whether or not the model wants it — a couple of connected servers can cost more
|
|
6
10
|
* tokens per request than the task's own prompt. So in on-demand mode the run starts with a
|
|
@@ -11,39 +15,48 @@
|
|
|
11
15
|
* Names alone cost roughly a fortieth of what the schemas cost, so a run that needs no tools
|
|
12
16
|
* pays almost nothing, and a run that needs three pays for three.
|
|
13
17
|
*/
|
|
14
|
-
export const LOAD_TOOLS =
|
|
15
|
-
/**
|
|
18
|
+
export const LOAD_TOOLS = 'load_tools';
|
|
19
|
+
/**
|
|
20
|
+
* Shallow freezing this one would leave `.function.description` — the part worth editing.
|
|
21
|
+
*
|
|
22
|
+
* @param value - What to freeze, in place, with every object and array reachable from it. A
|
|
23
|
+
* primitive passes through. Nothing stops the walk on a value that holds itself.
|
|
24
|
+
* @returns `value` itself, not a copy.
|
|
25
|
+
*/
|
|
16
26
|
function deepFreeze(value) {
|
|
17
|
-
if (value && typeof value ===
|
|
18
|
-
for (const held of Object.values(value))
|
|
27
|
+
if (value && typeof value === 'object') {
|
|
28
|
+
for (const held of Object.values(value)) {
|
|
19
29
|
deepFreeze(held);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
20
32
|
return Object.freeze(value);
|
|
21
33
|
}
|
|
22
34
|
/**
|
|
23
35
|
* One object for the life of the process — the agent loop asks for it on every iteration.
|
|
24
36
|
*
|
|
37
|
+
* @remarks
|
|
25
38
|
* Frozen because it is shared: one mutable export reached by every consumer in the process
|
|
26
39
|
* means a caller that edits the description in place has edited it for all of them, in a place
|
|
27
40
|
* nobody would think to look for the change.
|
|
28
41
|
*/
|
|
29
42
|
export const LOAD_TOOLS_DEFINITION = deepFreeze({
|
|
30
|
-
type:
|
|
43
|
+
type: FUNCTION_TOOL,
|
|
31
44
|
function: {
|
|
32
45
|
name: LOAD_TOOLS,
|
|
33
|
-
description:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
description: 'Load the full definitions of tools listed in the tool catalogue so you can call them. ' +
|
|
47
|
+
'Pass the exact names you need, or a trailing wildcard like `server__group__*` for a ' +
|
|
48
|
+
'whole group. The tools become callable on your next step — load them, then call them. ' +
|
|
49
|
+
'Load only what the task actually needs.',
|
|
37
50
|
parameters: {
|
|
38
|
-
type:
|
|
51
|
+
type: SchemaType.Object,
|
|
39
52
|
properties: {
|
|
40
53
|
names: {
|
|
41
|
-
type:
|
|
42
|
-
items: { type:
|
|
43
|
-
description:
|
|
54
|
+
type: SchemaType.Array,
|
|
55
|
+
items: { type: SchemaType.String },
|
|
56
|
+
description: 'Tool names from the catalogue. Wildcards may end with `*`.',
|
|
44
57
|
},
|
|
45
58
|
},
|
|
46
|
-
required: [
|
|
59
|
+
required: ['names'],
|
|
47
60
|
additionalProperties: false,
|
|
48
61
|
},
|
|
49
62
|
},
|
|
@@ -51,26 +64,36 @@ export const LOAD_TOOLS_DEFINITION = deepFreeze({
|
|
|
51
64
|
/**
|
|
52
65
|
* The catalogue as a plain grouped listing of names, loaded ones marked if asked.
|
|
53
66
|
*
|
|
67
|
+
* @param catalog - The connected servers. Ones with no tools are dropped.
|
|
68
|
+
* @param [loaded] - Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
|
|
69
|
+
* listing the same text for the whole run.
|
|
70
|
+
* @returns Each server's label on a line ending in a colon, its tool names indented beneath, one
|
|
71
|
+
* server straight after another. Empty where no server has a tool.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
54
74
|
* A server with no tools is dropped rather than titled: a pool hands one over whenever a
|
|
55
75
|
* server is connected but has nothing to offer, and a label with nothing under it reads as a
|
|
56
76
|
* listing that got cut off.
|
|
57
|
-
*
|
|
58
|
-
* @param catalog The connected servers. Ones with no tools are dropped.
|
|
59
|
-
* @param loaded Names to mark `(loaded)` rather than remove. Absent marks nothing, which keeps the
|
|
60
|
-
* listing the same text for the whole run.
|
|
61
77
|
*/
|
|
62
78
|
export function catalogList(catalog, loaded) {
|
|
63
79
|
return catalog
|
|
64
80
|
.filter((server) => server.tools.length > 0)
|
|
65
81
|
.map((server) => {
|
|
66
|
-
const names = server.tools.map((tool) => ` ${tool.name}${loaded?.has(tool.name) ?
|
|
67
|
-
return `${server.label}:\n${names.join(
|
|
82
|
+
const names = server.tools.map((tool) => ` ${tool.name}${loaded?.has(tool.name) ? ' (loaded)' : ''}`);
|
|
83
|
+
return `${server.label}:\n${names.join('\n')}`;
|
|
68
84
|
})
|
|
69
|
-
.join(
|
|
85
|
+
.join('\n');
|
|
70
86
|
}
|
|
71
87
|
/**
|
|
72
88
|
* The catalogue block appended to the system prompt. Names only — descriptions arrive on load.
|
|
73
89
|
*
|
|
90
|
+
* @param catalog - The connected servers. A catalogue with no tools in it produces an empty string.
|
|
91
|
+
* @param [loaded] - Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
|
|
92
|
+
* not mind the cache. Absent marks nothing.
|
|
93
|
+
* @returns The heading, the instructions and the listing, with no blank line before or after — the
|
|
94
|
+
* caller separates it from its own prompt. Empty where there is nothing to list.
|
|
95
|
+
*
|
|
96
|
+
* @remarks
|
|
74
97
|
* `runAgentLoop` passes no `loaded`, so the block is the same text on every step. The system
|
|
75
98
|
* prompt is the head of the request, and marking each load there threw away the prompt cache for
|
|
76
99
|
* the whole transcript on every `load_tools` call. What is loaded is said where it does not move
|
|
@@ -81,56 +104,101 @@ export function catalogList(catalog, loaded) {
|
|
|
81
104
|
* it was loaded, and the model loads again to get it back; hoisting them into a separate "already
|
|
82
105
|
* loaded" section splits a server's tools apart, and the model picks a sibling from the longer
|
|
83
106
|
* list instead.
|
|
84
|
-
*
|
|
85
|
-
* @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
|
|
86
|
-
* @param loaded Names to mark `(loaded)`, for a caller that rebuilds its prompt per load and does
|
|
87
|
-
* not mind the cache. Absent marks nothing.
|
|
88
107
|
*/
|
|
89
108
|
export function catalogPrompt(catalog, loaded) {
|
|
90
109
|
const list = catalogList(catalog, loaded);
|
|
91
110
|
// Not `catalog.length`: a catalogue of nothing but empty servers has no names to offer, and
|
|
92
111
|
// the preamble below would then explain a mechanism against an empty list.
|
|
93
|
-
if (!list)
|
|
94
|
-
return
|
|
112
|
+
if (!list) {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
95
115
|
return [
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
'# Tool catalogue',
|
|
117
|
+
'',
|
|
118
|
+
'These tools exist but are not loaded. Call `load_tools` with the names you need, then call',
|
|
119
|
+
'them on the step after. Names are descriptive; load a tool to see its parameters. A tool',
|
|
120
|
+
'already in your tool list is loaded — call it directly, do not load it again. Do not load',
|
|
121
|
+
'tools the task does not need, and do not mention this mechanism in your answer.',
|
|
122
|
+
'',
|
|
103
123
|
list,
|
|
104
|
-
].join(
|
|
124
|
+
].join('\n');
|
|
105
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Every tool in the catalogue as one list, server by server.
|
|
128
|
+
*
|
|
129
|
+
* @param catalog - The connected servers.
|
|
130
|
+
* @returns A new array in catalogue order. A name two servers offer is in it twice.
|
|
131
|
+
*/
|
|
106
132
|
const flatten = (catalog) => catalog.flatMap((server) => server.tools);
|
|
133
|
+
/**
|
|
134
|
+
* The name a tool definition is called by, or `undefined` for one that is not a function.
|
|
135
|
+
*
|
|
136
|
+
* @param tool - The definition, as a request declares it.
|
|
137
|
+
* @returns The function's name as the definition spells it — an empty one comes back empty, not
|
|
138
|
+
* `undefined`.
|
|
139
|
+
*
|
|
140
|
+
* @remarks
|
|
141
|
+
* Undefined rather than empty, so a caller that skips such a tool and one that has to place it
|
|
142
|
+
* somewhere each say which they mean — a sort and a positional comparison want `?? ""`, a lookup
|
|
143
|
+
* by name wants the tool left out.
|
|
144
|
+
*/
|
|
145
|
+
export const toolName = (tool) => tool.type === FUNCTION_TOOL ? tool.function.name : undefined;
|
|
107
146
|
/**
|
|
108
147
|
* A tool array with newly loaded definitions appended, in the order they were loaded.
|
|
109
148
|
*
|
|
149
|
+
* @param previous - What the last request declared, `load_tools` included. Not written to.
|
|
150
|
+
* @param matched - The definitions to add. Ones whose name is already declared, here or earlier in
|
|
151
|
+
* this list, are skipped rather than moved. One that is not a function has no name to be
|
|
152
|
+
* skipped by, and is appended.
|
|
153
|
+
* @returns A new array, `previous` first — a copy even where nothing was added.
|
|
154
|
+
*
|
|
155
|
+
* @remarks
|
|
110
156
|
* Appended and never rebuilt from a set, so what a load adds is decided by the load and not by
|
|
111
157
|
* the shape of whatever collection the definitions came out of. Where the appended array ends up
|
|
112
158
|
* in the request is `orderTools`' business, which the loop applies after this.
|
|
113
|
-
*
|
|
114
|
-
* @param previous What the last request declared, `load_tools` included. Not written to.
|
|
115
|
-
* @param matched The definitions to add. Ones whose name is already declared, here or earlier in
|
|
116
|
-
* this list, are skipped rather than moved.
|
|
117
159
|
*/
|
|
118
160
|
export function loadedTools(previous, matched) {
|
|
119
|
-
const
|
|
120
|
-
const declared = new Set(previous.map(nameOf));
|
|
161
|
+
const declared = new Set(previous.map(toolName));
|
|
121
162
|
const tools = [...previous];
|
|
122
163
|
for (const tool of matched) {
|
|
123
|
-
const name =
|
|
124
|
-
if (name !== undefined && declared.has(name))
|
|
164
|
+
const name = toolName(tool);
|
|
165
|
+
if (name !== undefined && declared.has(name)) {
|
|
125
166
|
continue;
|
|
167
|
+
}
|
|
126
168
|
declared.add(name);
|
|
127
169
|
tools.push(tool);
|
|
128
170
|
}
|
|
129
171
|
return tools;
|
|
130
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Orders two names by code unit.
|
|
175
|
+
*
|
|
176
|
+
* @param a - The name on the left.
|
|
177
|
+
* @param b - The name on the right.
|
|
178
|
+
* @returns Negative where `a` sorts first, positive where `b` does, zero where they are the same.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* Rather than `localeCompare`, whose answer depends on the host's locale — which is the kind of
|
|
182
|
+
* instability `orderTools` exists to remove.
|
|
183
|
+
*/
|
|
184
|
+
function byCodeUnit(a, b) {
|
|
185
|
+
if (a < b) {
|
|
186
|
+
return -1;
|
|
187
|
+
}
|
|
188
|
+
if (a > b) {
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
131
193
|
/**
|
|
132
194
|
* The tool array in a stable order, so the same set of tools renders the same way twice.
|
|
133
195
|
*
|
|
196
|
+
* @param tools - The definitions to order. Not written to.
|
|
197
|
+
* @param [order] - `true` for name order, `false` to leave it alone, or a comparator over the names.
|
|
198
|
+
* A tool that is not a function orders as the empty name.
|
|
199
|
+
* @returns `tools` itself when it is already in that order, so the common case copies nothing.
|
|
200
|
+
*
|
|
201
|
+
* @remarks
|
|
134
202
|
* A chat template renders the declared tools ahead of the system prompt, which makes the tool
|
|
135
203
|
* array the first thing a prompt cache has to match — and an array assembled from a map, from
|
|
136
204
|
* database rows, or from the order servers happened to connect in changes between processes and
|
|
@@ -142,25 +210,20 @@ export function loadedTools(previous, matched) {
|
|
|
142
210
|
*
|
|
143
211
|
* `false` is for a caller that means its order: the model reads the array top to bottom, and a
|
|
144
212
|
* host may be putting what it wants reached for first at the front.
|
|
145
|
-
*
|
|
146
|
-
* @param tools The definitions to order. Not written to.
|
|
147
|
-
* @param order `true` for name order, `false` to leave it alone, or a comparator over the names.
|
|
148
|
-
* A tool that is not a function orders as the empty name.
|
|
149
|
-
* @returns `tools` itself when it is already in that order, so the common case copies nothing.
|
|
150
213
|
*/
|
|
151
214
|
export function orderTools(tools, order = true) {
|
|
152
|
-
if (order === false)
|
|
215
|
+
if (order === false) {
|
|
153
216
|
return tools;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const compare = typeof order === "function" ? order : (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
217
|
+
}
|
|
218
|
+
const nameOf = (tool) => toolName(tool) ?? '';
|
|
219
|
+
const compare = typeof order === 'function' ? order : byCodeUnit;
|
|
158
220
|
const sorted = [...tools].sort((a, b) => compare(nameOf(a), nameOf(b)));
|
|
159
221
|
return sorted.some((tool, at) => tool !== tools[at]) ? sorted : tools;
|
|
160
222
|
}
|
|
161
223
|
/**
|
|
162
224
|
* The most a single `load_tools` call may pull in.
|
|
163
225
|
*
|
|
226
|
+
* @remarks
|
|
164
227
|
* A wildcard like `gmail__*` matches 33 tools, and loading them all puts the model right back
|
|
165
228
|
* in the position on-demand loading exists to avoid — a tool array too large to choose from.
|
|
166
229
|
* Over-broad requests are refused with the matching names listed, so the next call can be
|
|
@@ -176,6 +239,7 @@ export const MAX_PER_LOAD = 12;
|
|
|
176
239
|
* The most a conversation carries between turns. Bounds the tool array no matter how long the
|
|
177
240
|
* conversation runs; past it, the earliest-declared names this turn did not use fall off first.
|
|
178
241
|
*
|
|
242
|
+
* @remarks
|
|
179
243
|
* Only a multi-turn caller needs this — a run that starts from nothing each time has nothing to
|
|
180
244
|
* carry. See `carryOver`, which takes another number if this one is not yours.
|
|
181
245
|
*/
|
|
@@ -183,19 +247,21 @@ export const MAX_CARRIED = 16;
|
|
|
183
247
|
/**
|
|
184
248
|
* The tools to start the next turn with: last turn's where they were, then what this turn used.
|
|
185
249
|
*
|
|
250
|
+
* @param previous - Last turn's names, in the order they were declared.
|
|
251
|
+
* @param used - What this turn called. Names not already carried are appended in the order given,
|
|
252
|
+
* so pass them in load order to keep them where the tool array had them.
|
|
253
|
+
* @param [max] - How many to carry. Past it, the earliest names not in
|
|
254
|
+
* `used` go first, then the earliest of all. At least one: a cap of zero is read as one, not as
|
|
255
|
+
* no cap and not as none.
|
|
256
|
+
* @returns The names to carry, as a new array no longer than the cap.
|
|
257
|
+
*
|
|
258
|
+
* @remarks
|
|
186
259
|
* Nothing carried moves. A template renders the tool array near the head of the prompt, and
|
|
187
260
|
* moving each used tool to the end — which this did until it was found in a local server's cache
|
|
188
261
|
* log — reordered the array between turns and re-prefilled the whole transcript from the first
|
|
189
262
|
* moved definition. Kept in place, the next turn's array is this one's with only the unused
|
|
190
263
|
* loads gone, a prefix of it when every load was used. Past `max`, dropping a name from the
|
|
191
264
|
* middle costs the cache from there, so it happens only when the cap forces it.
|
|
192
|
-
*
|
|
193
|
-
* @param previous Last turn's names, in the order they were declared.
|
|
194
|
-
* @param used What this turn called. Names not already carried are appended in the order given,
|
|
195
|
-
* so pass them in load order to keep them where the tool array had them.
|
|
196
|
-
* @param max How many to carry, defaulting to `MAX_CARRIED`. Past it, the earliest names not in
|
|
197
|
-
* `used` go first, then the earliest of all. At least one: a cap of zero is read as one, not as
|
|
198
|
-
* no cap and not as none.
|
|
199
265
|
*/
|
|
200
266
|
export function carryOver(previous, used, max = MAX_CARRIED) {
|
|
201
267
|
const next = [...previous, ...[...used].filter((name) => !previous.includes(name))];
|
|
@@ -209,16 +275,19 @@ export function carryOver(previous, used, max = MAX_CARRIED) {
|
|
|
209
275
|
/**
|
|
210
276
|
* Resolves requested names against the catalogue, expanding trailing `*` wildcards.
|
|
211
277
|
*
|
|
278
|
+
* @param requested - What the model asked for. A trailing `*` expands.
|
|
279
|
+
* @param catalog - The servers to resolve against.
|
|
280
|
+
* @param [maxPerLoad] - The most this call may load. It comes back on
|
|
281
|
+
* the resolution so `loadResult` reports the same number rather than a second opinion of it.
|
|
282
|
+
* @returns The names that resolved, each once; the ones nothing matched; the asks too broad for
|
|
283
|
+
* any call, with what they matched; and the ones that only did not fit this call.
|
|
284
|
+
*
|
|
285
|
+
* @remarks
|
|
212
286
|
* Names are matched leniently. Catalogue entries are slug-qualified (`nas_fs__read_file`) and
|
|
213
287
|
* models routinely ask for the bare tool name, so an exact miss falls back to a suffix match
|
|
214
288
|
* on the `__` boundary — accepted only when it is unambiguous. Rejecting those outright just
|
|
215
289
|
* buys a wasted round trip while the model guesses the prefix, and pushes it toward
|
|
216
290
|
* shotgunning wildcards.
|
|
217
|
-
*
|
|
218
|
-
* @param requested What the model asked for. A trailing `*` expands.
|
|
219
|
-
* @param catalog The servers to resolve against.
|
|
220
|
-
* @param maxPerLoad The most this call may load, defaulting to `MAX_PER_LOAD`. It comes back on
|
|
221
|
-
* the resolution so `loadResult` reports the same number rather than a second opinion of it.
|
|
222
291
|
*/
|
|
223
292
|
export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
224
293
|
const all = flatten(catalog);
|
|
@@ -228,26 +297,29 @@ export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
|
228
297
|
const deferred = [];
|
|
229
298
|
const known = new Set(all.map((tool) => tool.name));
|
|
230
299
|
const resolve = (name) => {
|
|
231
|
-
if (name.endsWith(
|
|
300
|
+
if (name.endsWith('*')) {
|
|
232
301
|
const stem = name.slice(0, -1);
|
|
233
302
|
const direct = all.filter((tool) => tool.name.startsWith(stem));
|
|
234
|
-
if (direct.length)
|
|
303
|
+
if (direct.length) {
|
|
235
304
|
return direct.map((tool) => tool.name);
|
|
305
|
+
}
|
|
236
306
|
return all.filter((tool) => tool.name.includes(`__${stem}`)).map((tool) => tool.name);
|
|
237
307
|
}
|
|
238
|
-
if (known.has(name))
|
|
308
|
+
if (known.has(name)) {
|
|
239
309
|
return [name];
|
|
310
|
+
}
|
|
240
311
|
const suffix = all.filter((tool) => tool.name.endsWith(`__${name}`));
|
|
241
312
|
return suffix.length === 1 ? [suffix[0].name] : [];
|
|
242
313
|
};
|
|
243
314
|
for (const raw of requested) {
|
|
244
315
|
const name = raw.trim();
|
|
245
|
-
if (!name)
|
|
316
|
+
if (!name) {
|
|
246
317
|
continue;
|
|
318
|
+
}
|
|
247
319
|
// A bare `*` is not a guess at a name, it is a refusal to choose, and its empty stem
|
|
248
320
|
// prefixes every tool in the catalogue. Answer it the way any other over-broad request is
|
|
249
321
|
// answered: with the names, so the next call can pick from them.
|
|
250
|
-
if (name ===
|
|
322
|
+
if (name === '*') {
|
|
251
323
|
overBroad.push({ name, hits: all.map((tool) => tool.name) });
|
|
252
324
|
continue;
|
|
253
325
|
}
|
|
@@ -267,82 +339,105 @@ export function expandNames(requested, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
|
267
339
|
// enough already — telling a model that asked for one exact tool that it matches one tool,
|
|
268
340
|
// "more than the twelve one call may load", and to choose from a list holding just that
|
|
269
341
|
// name, leaves it nothing to do but send the identical call again.
|
|
270
|
-
if (fresh.length > maxPerLoad)
|
|
342
|
+
if (fresh.length > maxPerLoad) {
|
|
271
343
|
overBroad.push({ name, hits });
|
|
272
|
-
|
|
344
|
+
}
|
|
345
|
+
else if (matched.size + fresh.length > maxPerLoad) {
|
|
273
346
|
deferred.push(name);
|
|
274
|
-
|
|
275
|
-
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
for (const hit of hits) {
|
|
276
350
|
matched.add(hit);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
277
353
|
}
|
|
278
354
|
return { matched: [...matched], unknown, overBroad, deferred, maxPerLoad };
|
|
279
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Each block's lines on their own, and a blank line between one block and the next.
|
|
358
|
+
*
|
|
359
|
+
* @param blocks - The blocks, each as its lines.
|
|
360
|
+
* @returns One text, empty where there are no blocks.
|
|
361
|
+
*/
|
|
362
|
+
const joinBlocks = (blocks) => blocks.map((block) => block.join('\n')).join('\n\n');
|
|
280
363
|
/**
|
|
281
364
|
* What `load_tools` reports back: the descriptions, now that they are worth their tokens.
|
|
282
365
|
*
|
|
366
|
+
* @param expanded - What `expandNames` resolved: the matches, the misses, and the over-broad asks.
|
|
367
|
+
* @param catalog - The servers, read for the descriptions now worth their tokens.
|
|
368
|
+
* @param [loaded] - What was loaded before this call. Absent reports every match as newly loaded.
|
|
369
|
+
* @returns What the model reads, a blank line between each thing it is told: what was loaded with
|
|
370
|
+
* its descriptions, what already was, each over-broad ask with its matches, what did not fit this
|
|
371
|
+
* call, and what is not in the catalogue. `No tool names were given.` where there is none of them.
|
|
372
|
+
*
|
|
373
|
+
* @remarks
|
|
283
374
|
* A name that was loaded before this call is reported as already loaded rather than loaded
|
|
284
375
|
* again. The catalogue no longer marks what is loaded — see `catalogPrompt` — so this is where a
|
|
285
376
|
* model that asks twice finds out it need not have, and is told to call the tool instead.
|
|
286
|
-
*
|
|
287
|
-
* @param expanded What `expandNames` resolved: the matches, the misses, and the over-broad asks.
|
|
288
|
-
* @param catalog The servers, read for the descriptions now worth their tokens.
|
|
289
|
-
* @param loaded What was loaded before this call. Absent reports every match as newly loaded.
|
|
290
377
|
*/
|
|
291
378
|
export function loadResult({ matched, unknown, overBroad, deferred, maxPerLoad }, catalog, loaded) {
|
|
292
379
|
const byName = new Map(flatten(catalog).map((tool) => [tool.name, tool.description]));
|
|
293
|
-
|
|
380
|
+
// One entry per thing the model is told, each a blank line from the next.
|
|
381
|
+
const blocks = [];
|
|
294
382
|
const fresh = matched.filter((name) => !loaded?.has(name));
|
|
295
383
|
const again = matched.filter((name) => loaded?.has(name));
|
|
296
384
|
if (fresh.length) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
385
|
+
blocks.push([
|
|
386
|
+
`Loaded ${fresh.length} tool(s); they are callable on your next step.`,
|
|
387
|
+
'',
|
|
388
|
+
...fresh.map((name) => `${name}: ${byName.get(name) ?? ''}`.trim()),
|
|
389
|
+
]);
|
|
300
390
|
}
|
|
301
391
|
if (again.length) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
for (const { name, hits } of overBroad) {
|
|
307
|
-
if (lines.length)
|
|
308
|
-
lines.push("");
|
|
309
|
-
lines.push(`\`${name}\` matches ${hits.length} tools, more than the ${maxPerLoad} one call may load.`, "Name the ones you need from:", ...hits.map((hit) => ` ${hit}`));
|
|
392
|
+
blocks.push([
|
|
393
|
+
`Already loaded and in your tool list: ${again.join(', ')}. Call them directly; do not load them again.`,
|
|
394
|
+
]);
|
|
310
395
|
}
|
|
396
|
+
blocks.push(...overBroad.map(({ name, hits }) => [
|
|
397
|
+
`\`${name}\` matches ${hits.length} tools, more than the ${maxPerLoad} one call may load.`,
|
|
398
|
+
'Name the ones you need from:',
|
|
399
|
+
...hits.map((hit) => ` ${hit}`),
|
|
400
|
+
]));
|
|
311
401
|
if (deferred.length) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
402
|
+
blocks.push([
|
|
403
|
+
`This call is full at ${maxPerLoad} tools, so these were not loaded: ${deferred.join(', ')}.`,
|
|
404
|
+
'Ask for them on your next step.',
|
|
405
|
+
]);
|
|
315
406
|
}
|
|
316
407
|
if (unknown.length) {
|
|
317
|
-
|
|
318
|
-
lines.push("");
|
|
319
|
-
lines.push(`Not in the catalogue: ${unknown.join(", ")}. Check the names and try again.`);
|
|
408
|
+
blocks.push([`Not in the catalogue: ${unknown.join(', ')}. Check the names and try again.`]);
|
|
320
409
|
}
|
|
321
|
-
return
|
|
410
|
+
return joinBlocks(blocks) || 'No tool names were given.';
|
|
322
411
|
}
|
|
323
412
|
/**
|
|
324
413
|
* Whether the catalogue holds a tool by this name.
|
|
325
414
|
*
|
|
326
|
-
* @param catalog The connected servers and the tools each one offers.
|
|
327
|
-
* @param name An exact name. Nothing is prefixed, trimmed or fuzzily matched.
|
|
415
|
+
* @param catalog - The connected servers and the tools each one offers.
|
|
416
|
+
* @param name - An exact name. Nothing is prefixed, trimmed or fuzzily matched.
|
|
417
|
+
* @returns True where any server offers it, which no server of an empty catalogue does.
|
|
328
418
|
*/
|
|
329
419
|
export const inCatalog = (catalog, name) => catalog.some((server) => server.tools.some((tool) => tool.name === name));
|
|
330
420
|
/**
|
|
331
421
|
* `load_tools` arguments, defensively — a model may send a bare string or a nested object.
|
|
332
422
|
*
|
|
333
|
-
* @param args The tool call's arguments, exactly as the model sent them.
|
|
423
|
+
* @param args - The tool call's arguments, exactly as the model sent them.
|
|
424
|
+
* @returns The names under `names`, or failing that `tools`, or `name`: a string as a list of one,
|
|
425
|
+
* an array less whatever in it is not a string, and anything else as none. Not trimmed.
|
|
334
426
|
*/
|
|
335
427
|
export function requestedNames(args) {
|
|
336
428
|
const value = args.names ?? args.tools ?? args.name;
|
|
337
|
-
if (typeof value ===
|
|
429
|
+
if (typeof value === 'string') {
|
|
338
430
|
return [value];
|
|
339
|
-
|
|
340
|
-
|
|
431
|
+
}
|
|
432
|
+
if (Array.isArray(value)) {
|
|
433
|
+
return value.filter((item) => typeof item === 'string');
|
|
434
|
+
}
|
|
341
435
|
return [];
|
|
342
436
|
}
|
|
343
437
|
/**
|
|
344
438
|
* The one tool a proxied run reaches every catalogued tool through.
|
|
345
439
|
*
|
|
440
|
+
* @remarks
|
|
346
441
|
* Proxied discovery is on-demand loading with a tool array that never changes. On-demand mode
|
|
347
442
|
* declares each tool as it is loaded, and a chat template renders the tool array in the system
|
|
348
443
|
* turn, ahead of the whole conversation — so appending one definition there moves every token
|
|
@@ -353,11 +448,12 @@ export function requestedNames(args) {
|
|
|
353
448
|
* this one. The price is a level of indirection the model has to get right, which a small model
|
|
354
449
|
* does less reliably than a native call.
|
|
355
450
|
*/
|
|
356
|
-
export const CALL_TOOL =
|
|
451
|
+
export const CALL_TOOL = 'call_tool';
|
|
357
452
|
/**
|
|
358
453
|
* The whole tool array of a proxied run, `load_tools` then `call_tool`, frozen because it is
|
|
359
454
|
* shared.
|
|
360
455
|
*
|
|
456
|
+
* @remarks
|
|
361
457
|
* The load keeps `LOAD_TOOLS` as its name, so `requestedNames` and `expandNames` read its
|
|
362
458
|
* arguments as they do on demand; only what it promises differs, which is why it is not
|
|
363
459
|
* `LOAD_TOOLS_DEFINITION`. `call_tool`'s `arguments` says `additionalProperties: true` and has to:
|
|
@@ -367,44 +463,44 @@ export const CALL_TOOL = "call_tool";
|
|
|
367
463
|
*/
|
|
368
464
|
export const PROXY_TOOLS = deepFreeze([
|
|
369
465
|
{
|
|
370
|
-
type:
|
|
466
|
+
type: FUNCTION_TOOL,
|
|
371
467
|
function: {
|
|
372
468
|
name: LOAD_TOOLS,
|
|
373
|
-
description:
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
469
|
+
description: 'Get the full definitions of tools listed in the tool catalogue: what each does and the ' +
|
|
470
|
+
'arguments it takes. Pass the exact names you need, or a trailing wildcard like ' +
|
|
471
|
+
'`server__group__*` for a whole group. Then run them with `call_tool`. Load only what ' +
|
|
472
|
+
'the task actually needs.',
|
|
377
473
|
parameters: {
|
|
378
|
-
type:
|
|
474
|
+
type: SchemaType.Object,
|
|
379
475
|
properties: {
|
|
380
476
|
names: {
|
|
381
|
-
type:
|
|
382
|
-
items: { type:
|
|
383
|
-
description:
|
|
477
|
+
type: SchemaType.Array,
|
|
478
|
+
items: { type: SchemaType.String },
|
|
479
|
+
description: 'Tool names from the catalogue. Wildcards may end with `*`.',
|
|
384
480
|
},
|
|
385
481
|
},
|
|
386
|
-
required: [
|
|
482
|
+
required: ['names'],
|
|
387
483
|
additionalProperties: false,
|
|
388
484
|
},
|
|
389
485
|
},
|
|
390
486
|
},
|
|
391
487
|
{
|
|
392
|
-
type:
|
|
488
|
+
type: FUNCTION_TOOL,
|
|
393
489
|
function: {
|
|
394
490
|
name: CALL_TOOL,
|
|
395
|
-
description:
|
|
396
|
-
|
|
491
|
+
description: 'Run a tool from the catalogue. Load it with `load_tools` first to learn its arguments, ' +
|
|
492
|
+
'then pass its exact name and an arguments object matching its parameters.',
|
|
397
493
|
parameters: {
|
|
398
|
-
type:
|
|
494
|
+
type: SchemaType.Object,
|
|
399
495
|
properties: {
|
|
400
|
-
name: { type:
|
|
496
|
+
name: { type: SchemaType.String, description: "The tool's exact name from the catalogue." },
|
|
401
497
|
arguments: {
|
|
402
|
-
type:
|
|
498
|
+
type: SchemaType.Object,
|
|
403
499
|
description: "The tool's arguments, as its definition describes them.",
|
|
404
500
|
additionalProperties: true,
|
|
405
501
|
},
|
|
406
502
|
},
|
|
407
|
-
required: [
|
|
503
|
+
required: ['name', 'arguments'],
|
|
408
504
|
},
|
|
409
505
|
},
|
|
410
506
|
},
|
|
@@ -413,26 +509,29 @@ export const PROXY_TOOLS = deepFreeze([
|
|
|
413
509
|
* The catalogue block for a proxied run's system prompt, worded for `call_tool` rather than for a
|
|
414
510
|
* tool list.
|
|
415
511
|
*
|
|
512
|
+
* @param catalog - The connected servers. A catalogue with no tools in it produces an empty string.
|
|
513
|
+
* @returns The heading, the instructions and the listing, with no blank line before or after.
|
|
514
|
+
*
|
|
515
|
+
* @remarks
|
|
416
516
|
* `catalogPrompt` tells the model a loaded tool is in its tool list, which proxied is never true —
|
|
417
517
|
* a model told so looks for it there and calls it natively. The listing itself is the same, and
|
|
418
518
|
* like that one it is never marked, so the head of the prompt is one text for the whole session.
|
|
419
|
-
*
|
|
420
|
-
* @param catalog The connected servers. A catalogue with no tools in it produces an empty string.
|
|
421
519
|
*/
|
|
422
520
|
export function proxyCatalogPrompt(catalog) {
|
|
423
521
|
const list = catalogList(catalog);
|
|
424
|
-
if (!list)
|
|
425
|
-
return
|
|
522
|
+
if (!list) {
|
|
523
|
+
return '';
|
|
524
|
+
}
|
|
426
525
|
return [
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
526
|
+
'# Tool catalogue',
|
|
527
|
+
'',
|
|
528
|
+
'These tools exist. Call `load_tools` with the names you need to get their definitions, then',
|
|
529
|
+
'run them with `call_tool`. Names are descriptive; load a tool to see its parameters. A tool',
|
|
530
|
+
'whose definition is already in this conversation does not need loading again. Do not load',
|
|
531
|
+
'tools the task does not need, and do not mention this mechanism in your answer.',
|
|
532
|
+
'',
|
|
434
533
|
list,
|
|
435
|
-
].join(
|
|
534
|
+
].join('\n');
|
|
436
535
|
}
|
|
437
536
|
/** How a proxied load result that carries definitions opens, with the count left open. */
|
|
438
537
|
const PROXY_LOADED = /^Loaded \d+ tool\(s\)\. Run them with `call_tool`\./;
|
|
@@ -440,314 +539,129 @@ const PROXY_LOADED = /^Loaded \d+ tool\(s\)\. Run them with `call_tool`\./;
|
|
|
440
539
|
* Whether a tool result is a proxied load carrying definitions, which is the only copy of them
|
|
441
540
|
* the model has.
|
|
442
541
|
*
|
|
542
|
+
* @param result - The tool message's text. A load that only pointed back or refused is not one.
|
|
543
|
+
* @returns True where the text opens as `proxyLoadResult` opens a result with a definition in it.
|
|
544
|
+
*
|
|
545
|
+
* @remarks
|
|
443
546
|
* On demand the definitions are in the tool array and a load's result only repeats their
|
|
444
547
|
* descriptions, so clearing it loses nothing. Proxied, the result is the schema, and
|
|
445
548
|
* `pruneToolResults` asks this before it stubs one. Told by how `proxyLoadResult` opens, the way
|
|
446
549
|
* a summary is told by `SUMMARY_LEAD`, because a `tool` message carries no tool name of its own.
|
|
447
|
-
*
|
|
448
|
-
* @param result The tool message's text. A load that only pointed back or refused is not one.
|
|
449
550
|
*/
|
|
450
551
|
export const holdsDefinitions = (result) => PROXY_LOADED.test(result);
|
|
451
552
|
/**
|
|
452
553
|
* What a proxied `load_tools` answers: each new tool's whole definition as a line of JSON, since
|
|
453
554
|
* the result is the only place the model will ever see it.
|
|
454
555
|
*
|
|
556
|
+
* @param resolved - What the call asked for, from `expandNames`.
|
|
557
|
+
* @param catalog - The servers, read for the refusals' wording.
|
|
558
|
+
* @param definitions - The definitions of `resolved.matched`, as many as the host has. Ones that
|
|
559
|
+
* are not functions are skipped.
|
|
560
|
+
* @param [loaded] - What was loaded before this call. Absent reports every match as newly loaded.
|
|
561
|
+
* @returns What the model reads, a blank line between each part: the count and one line of JSON
|
|
562
|
+
* per new definition, then what was already loaded, what has no definition, and the refusals.
|
|
563
|
+
* Never empty.
|
|
564
|
+
*
|
|
565
|
+
* @remarks
|
|
455
566
|
* A name loaded before this call is answered with a pointer back rather than its definition a
|
|
456
567
|
* second time, and one the catalogue lists but `definitions` does not hold is said to have none,
|
|
457
568
|
* so the model is not left waiting on a schema that is not coming. The refusals — too broad, over
|
|
458
569
|
* the per-call cap, not in the catalogue, nothing asked for — are `loadResult`'s, worded the same
|
|
459
570
|
* in both modes.
|
|
460
|
-
*
|
|
461
|
-
* @param resolved What the call asked for, from `expandNames`.
|
|
462
|
-
* @param catalog The servers, read for the refusals' wording.
|
|
463
|
-
* @param definitions The definitions of `resolved.matched`, as many as the host has. Ones that
|
|
464
|
-
* are not functions are skipped.
|
|
465
|
-
* @param loaded What was loaded before this call. Absent reports every match as newly loaded.
|
|
466
571
|
*/
|
|
467
572
|
export function proxyLoadResult(resolved, catalog, definitions, loaded) {
|
|
468
|
-
const
|
|
573
|
+
const blocks = [];
|
|
469
574
|
const again = resolved.matched.filter((name) => loaded?.has(name));
|
|
470
|
-
const fresh = definitions.flatMap((tool) => tool.type ===
|
|
575
|
+
const fresh = definitions.flatMap((tool) => tool.type === FUNCTION_TOOL && !loaded?.has(tool.function.name) ? [tool.function] : []);
|
|
471
576
|
const defined = new Set(fresh.map((tool) => tool.name));
|
|
472
577
|
const missing = resolved.matched.filter((name) => !loaded?.has(name) && !defined.has(name));
|
|
473
578
|
if (fresh.length) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
lines.push("", JSON.stringify({ name, description, parameters }));
|
|
579
|
+
blocks.push([`Loaded ${fresh.length} tool(s). Run them with \`${CALL_TOOL}\`.`]);
|
|
580
|
+
blocks.push(...fresh.map(({ name, description, parameters }) => [JSON.stringify({ name, description, parameters })]));
|
|
477
581
|
}
|
|
478
582
|
if (again.length) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
583
|
+
blocks.push([
|
|
584
|
+
`Already loaded earlier in this conversation: ${again.join(', ')}. Run them with ` +
|
|
585
|
+
`\`${CALL_TOOL}\`; do not load them again.`,
|
|
586
|
+
]);
|
|
483
587
|
}
|
|
484
588
|
if (missing.length) {
|
|
485
|
-
|
|
486
|
-
lines.push("");
|
|
487
|
-
lines.push(`No definition is available for: ${missing.join(", ")}.`);
|
|
589
|
+
blocks.push([`No definition is available for: ${missing.join(', ')}.`]);
|
|
488
590
|
}
|
|
489
591
|
const { overBroad, deferred, unknown, matched } = resolved;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
lines.push(loadResult({ ...resolved, matched: [] }, catalog));
|
|
592
|
+
const hasMoreToSay = overBroad.length || deferred.length || unknown.length || !matched.length;
|
|
593
|
+
if (hasMoreToSay) {
|
|
594
|
+
blocks.push([loadResult({ ...resolved, matched: [] }, catalog)]);
|
|
494
595
|
}
|
|
495
|
-
return
|
|
596
|
+
return joinBlocks(blocks);
|
|
496
597
|
}
|
|
497
598
|
/**
|
|
498
599
|
* The tool a `call_tool` names and the arguments to run it with, or a throw the model can read.
|
|
499
600
|
*
|
|
601
|
+
* @param args - The `call_tool` call's own arguments, parsed. An absent `arguments` is no arguments.
|
|
602
|
+
* @param catalog - What may be called. The name is trimmed and then matched exactly.
|
|
603
|
+
* @returns The trimmed name, and the arguments as an object: the one the model passed, not a copy,
|
|
604
|
+
* or the one its JSON string parsed to.
|
|
605
|
+
*
|
|
606
|
+
* @remarks
|
|
500
607
|
* `arguments` arrives as an object when the model follows the schema and as a JSON string when it
|
|
501
608
|
* copies the shape of a native call instead; both are taken, since the intent is the same. A name
|
|
502
609
|
* outside the catalogue is refused here rather than handed to a dispatcher, which is what keeps
|
|
503
610
|
* `call_tool` from reaching anything the catalogue does not offer.
|
|
504
|
-
*
|
|
505
|
-
* @param args The `call_tool` call's own arguments, parsed. An absent `arguments` is no arguments.
|
|
506
|
-
* @param catalog What may be called. The name is trimmed and then matched exactly.
|
|
507
611
|
*/
|
|
508
612
|
export function proxiedCall(args, catalog) {
|
|
509
|
-
const name = typeof args.name ===
|
|
510
|
-
if (!name)
|
|
613
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
614
|
+
if (!name) {
|
|
511
615
|
throw new Error(`${CALL_TOOL} needs a name; pass one from the tool catalogue.`);
|
|
512
|
-
|
|
616
|
+
}
|
|
617
|
+
if (!inCatalog(catalog, name)) {
|
|
513
618
|
throw new Error(`Not in the catalogue: ${name}. Check the name and try again.`);
|
|
619
|
+
}
|
|
514
620
|
let input = args.arguments ?? {};
|
|
515
|
-
if (typeof input ===
|
|
621
|
+
if (typeof input === 'string') {
|
|
516
622
|
const text = input;
|
|
517
623
|
try {
|
|
518
624
|
input = text.trim() ? JSON.parse(text) : {};
|
|
519
625
|
}
|
|
520
626
|
catch {
|
|
521
|
-
throw new Error(`${CALL_TOOL} arguments for ${name} are not valid JSON: ${text.slice(0,
|
|
627
|
+
throw new Error(`${CALL_TOOL} arguments for ${name} are not valid JSON: ${text.slice(0, ARGUMENT_PREVIEW_CHARS)}`);
|
|
522
628
|
}
|
|
523
629
|
}
|
|
524
|
-
if (!
|
|
630
|
+
if (!isRecord(input)) {
|
|
525
631
|
throw new Error(`${CALL_TOOL} arguments for ${name} must be an object.`);
|
|
526
|
-
|
|
632
|
+
}
|
|
633
|
+
return { name, input };
|
|
527
634
|
}
|
|
528
635
|
/**
|
|
529
636
|
* A tool call as a watcher should see it: a `call_tool` as the tool it ran, anything else as it
|
|
530
637
|
* is.
|
|
531
638
|
*
|
|
639
|
+
* @param name - The name the model called.
|
|
640
|
+
* @param input - The call's arguments as JSON text.
|
|
641
|
+
* @returns The inner name and its arguments as JSON text — the string itself where the model
|
|
642
|
+
* passed `arguments` as one.
|
|
643
|
+
*
|
|
644
|
+
* @remarks
|
|
532
645
|
* The transcript keeps the `call_tool` the model wrote, since the next request has to repeat it
|
|
533
646
|
* word for word to stay in the cache; only the display looks through it. Anything that does not
|
|
534
647
|
* parse as a proxied call is left as it came.
|
|
535
|
-
*
|
|
536
|
-
* @param name The name the model called.
|
|
537
|
-
* @param input The call's arguments as JSON text.
|
|
538
|
-
* @returns The inner name and its arguments as JSON text — the string itself where the model
|
|
539
|
-
* passed `arguments` as one.
|
|
540
648
|
*/
|
|
541
649
|
export function shownCall(name, input) {
|
|
542
|
-
if (name !== CALL_TOOL)
|
|
650
|
+
if (name !== CALL_TOOL) {
|
|
543
651
|
return { name, input };
|
|
652
|
+
}
|
|
544
653
|
try {
|
|
545
654
|
const args = JSON.parse(input);
|
|
546
|
-
if (typeof args.name !==
|
|
655
|
+
if (typeof args.name !== 'string' || !args.name.trim()) {
|
|
547
656
|
return { name, input };
|
|
657
|
+
}
|
|
548
658
|
const inner = args.arguments ?? {};
|
|
549
659
|
return {
|
|
550
660
|
name: args.name.trim(),
|
|
551
|
-
input: typeof inner ===
|
|
661
|
+
input: typeof inner === 'string' ? inner : JSON.stringify(inner),
|
|
552
662
|
};
|
|
553
663
|
}
|
|
554
664
|
catch {
|
|
555
665
|
return { name, input };
|
|
556
666
|
}
|
|
557
667
|
}
|
|
558
|
-
/**
|
|
559
|
-
* Where a request is cut for the preselector, in characters.
|
|
560
|
-
*
|
|
561
|
-
* A tool choice is made on what the work is, which is the top of a request rather than all of
|
|
562
|
-
* it — and the whole of a long one is paid for again in the preselection call. `preselectInput`
|
|
563
|
-
* takes another number for a caller whose requests are not shaped that way.
|
|
564
|
-
*/
|
|
565
|
-
const PRESELECT_PROMPT_CHARS = 2000;
|
|
566
|
-
/**
|
|
567
|
-
* The system prompt a preselector is given, holding it to the cap its answer will be held to.
|
|
568
|
-
*
|
|
569
|
-
* On-demand loading otherwise costs a round trip every run: the model reads the catalogue,
|
|
570
|
-
* calls `load_tools`, and only then can do the work. A small model reading the same catalogue
|
|
571
|
-
* usually names the right tools outright, so the task model finds them already loaded and
|
|
572
|
-
* starts working on its first step.
|
|
573
|
-
*
|
|
574
|
-
* A wrong guess is cheap — an unused definition is a few hundred tokens for one run — but a
|
|
575
|
-
* broad guess is not, so the same cap applies here as to a `load_tools` call.
|
|
576
|
-
*
|
|
577
|
-
* @param maxPerLoad The most to ask for, defaulting to `MAX_PER_LOAD`. Give `preselection` the
|
|
578
|
-
* same number: this one is what the preselector is told, and that one is what it is held to.
|
|
579
|
-
*/
|
|
580
|
-
export const preselectSystem = (maxPerLoad = MAX_PER_LOAD) => "You choose tools. Below is a catalogue of tool names, then a request. Reply with a JSON " +
|
|
581
|
-
'object whose "tools" array holds the names the request is likely to need — exact names from ' +
|
|
582
|
-
`the catalogue, at most ${maxPerLoad}, and as few as could do the job. Reply with ` +
|
|
583
|
-
'`{"tools": []}` if the request can be answered without tools. Reply with the object alone — ' +
|
|
584
|
-
"no prose, no explanation.";
|
|
585
|
-
/**
|
|
586
|
-
* The shape a preselector's answer is held to where the server takes a schema: `{ tools: [...] }`.
|
|
587
|
-
*
|
|
588
|
-
* An object around the array rather than the array, because a structured answer's root has to be
|
|
589
|
-
* an object — OpenAI's strict mode and every tool-schema normaliser insist.
|
|
590
|
-
*/
|
|
591
|
-
export const PRESELECT_SCHEMA = {
|
|
592
|
-
type: "object",
|
|
593
|
-
properties: { tools: { type: "array", items: { type: "string" } } },
|
|
594
|
-
required: ["tools"],
|
|
595
|
-
additionalProperties: false,
|
|
596
|
-
};
|
|
597
|
-
/** The preselection system prompt at the default cap, for a caller that never changes it. */
|
|
598
|
-
export const PRESELECT_SYSTEM = preselectSystem();
|
|
599
|
-
/**
|
|
600
|
-
* The user message for a preselection call: the catalogue, then the request.
|
|
601
|
-
*
|
|
602
|
-
* @param catalog The connected servers, rendered as the name-only listing.
|
|
603
|
-
* @param prompt The request being planned for, truncated — choosing tools needs the shape of the
|
|
604
|
-
* ask, not all of it.
|
|
605
|
-
* @param maxPromptChars Where the request is cut, defaulting to 2000. A caller whose requests
|
|
606
|
-
* carry the part that names the work at the end wants a larger one, and pays for it in the
|
|
607
|
-
* preselector's prompt.
|
|
608
|
-
*/
|
|
609
|
-
export const preselectInput = (catalog, prompt, maxPromptChars = PRESELECT_PROMPT_CHARS) => `# Tool catalogue\n\n${catalogList(catalog)}\n\n# Request\n\n${prompt.slice(0, maxPromptChars)}`;
|
|
610
|
-
/**
|
|
611
|
-
* Resolves a preselection against the catalogue: unknown names dropped, count capped.
|
|
612
|
-
*
|
|
613
|
-
* @param names What the preselector replied: `{ tools: [...] }` as `PRESELECT_SCHEMA` has it, or
|
|
614
|
-
* the bare array an older prompt asked for. Unvalidated: anything else gives none, and entries
|
|
615
|
-
* that are not strings are dropped.
|
|
616
|
-
* @param catalog The servers to resolve against.
|
|
617
|
-
* @param maxPerLoad The most to keep, defaulting to `MAX_PER_LOAD`. The same number
|
|
618
|
-
* `preselectSystem` was given, or the model is being held to a cap it was never told about.
|
|
619
|
-
*/
|
|
620
|
-
export function preselection(names, catalog, maxPerLoad = MAX_PER_LOAD) {
|
|
621
|
-
const list = names && typeof names === "object" && !Array.isArray(names)
|
|
622
|
-
? names.tools
|
|
623
|
-
: names;
|
|
624
|
-
if (!Array.isArray(list))
|
|
625
|
-
return [];
|
|
626
|
-
const wanted = list.filter((name) => typeof name === "string");
|
|
627
|
-
return expandNames(wanted, catalog, maxPerLoad).matched.slice(0, maxPerLoad);
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Saturation and length normalisation for the BM25 score. Robertson's usual values.
|
|
631
|
-
*
|
|
632
|
-
* Nothing here is tuned for this corpus, because tuning them against a catalogue of forty short
|
|
633
|
-
* documents would be fitting noise. `dropoff` and `minScore` are the knobs worth turning.
|
|
634
|
-
*/
|
|
635
|
-
const BM25_K1 = 1.2;
|
|
636
|
-
const BM25_B = 0.75;
|
|
637
|
-
/**
|
|
638
|
-
* The least a best match may score and still be acted on without a model.
|
|
639
|
-
*
|
|
640
|
-
* A BM25 score, so it is read against the shape of the corpus rather than as a percentage: a
|
|
641
|
-
* query term carried by half the catalogue is worth about 0.7, and one carried by a single tool
|
|
642
|
-
* about 3. One at this floor is therefore "something more distinctive than a word every other
|
|
643
|
-
* tool uses", which is the weakest evidence worth skipping a round trip on.
|
|
644
|
-
*
|
|
645
|
-
* A term is distinctive only against other terms, so a catalogue of three or four tools rarely
|
|
646
|
-
* clears it. That is the right answer rather than a gap: a catalogue that small is not costing
|
|
647
|
-
* enough tokens to be worth choosing from in the first place.
|
|
648
|
-
*/
|
|
649
|
-
export const KEYWORD_MIN_SCORE = 1;
|
|
650
|
-
/**
|
|
651
|
-
* How far the best unpicked tool must fall below the last picked one for the cut to count clean.
|
|
652
|
-
*
|
|
653
|
-
* Half. The cap is the only reason a hit is dropped, so a hit just underneath it scoring nearly
|
|
654
|
-
* as much as one just above means the ranking chose arbitrarily, which is exactly the case a
|
|
655
|
-
* model should be spent on.
|
|
656
|
-
*/
|
|
657
|
-
export const KEYWORD_DROPOFF = 0.5;
|
|
658
|
-
/**
|
|
659
|
-
* English function words, dropped before matching.
|
|
660
|
-
*
|
|
661
|
-
* The inverse document frequency is supposed to make this unnecessary, and over a real corpus it
|
|
662
|
-
* would: a word carried by every document is worth nothing. But a tool catalogue is twenty
|
|
663
|
-
* one-line descriptions, and at that size "for" or "on" is rare by accident — it lands in one
|
|
664
|
-
* description, scores as the most distinctive term in the query, and a request that says "for me"
|
|
665
|
-
* is answered with whichever tool happened to use the word. Only closure-class words are here;
|
|
666
|
-
* "list", "get", "run" and "show" are what tools are called and stay.
|
|
667
|
-
*/
|
|
668
|
-
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 " +
|
|
669
|
-
"has have how if in into is it its just me more most my no not of on or other our out over " +
|
|
670
|
-
"please should so some such than that the their them then there these they this to too up us " +
|
|
671
|
-
"very was we were what when where which who will with would you your").split(" "));
|
|
672
|
-
/**
|
|
673
|
-
* A text as the matcher reads it: lowercase words, `server__tool_name` and camelCase split apart.
|
|
674
|
-
*
|
|
675
|
-
* Plurals are folded, crudely, by dropping a trailing `s`: a request says "read the files" and
|
|
676
|
-
* the tool is called `read_file`, and without this the two do not meet. Nothing else is stemmed —
|
|
677
|
-
* a real stemmer is a table of English morphology, and this is matching identifiers.
|
|
678
|
-
*/
|
|
679
|
-
const terms = (text) => text
|
|
680
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
681
|
-
.toLowerCase()
|
|
682
|
-
.split(/[^a-z0-9]+/)
|
|
683
|
-
.filter((word) => word.length > 1 && !NOISE.has(word))
|
|
684
|
-
.map((word) => word.length > 3 && word.endsWith("s") && !word.endsWith("ss") ? word.slice(0, -1) : word);
|
|
685
|
-
/**
|
|
686
|
-
* The tools a request's own words point at, ranked, and whether they point clearly enough.
|
|
687
|
-
*
|
|
688
|
-
* A preselection call costs a round trip to a model that is being asked to do term matching, and
|
|
689
|
-
* on a local box that is seconds before the run has started. For a catalogue of a few dozen tools
|
|
690
|
-
* the words usually decide it: a request that says "commit" and a tool called `git__commit` need
|
|
691
|
-
* no reasoning to connect.
|
|
692
|
-
*
|
|
693
|
-
* BM25 rather than counting shared words, because the ranking has to survive the words every tool
|
|
694
|
-
* uses. "list", "get" and "file" are in half the descriptions in a real catalogue, and a plain
|
|
695
|
-
* overlap count hands the top of the ranking to whichever tool has the longest description. The
|
|
696
|
-
* inverse document frequency makes a term worth what it distinguishes, and the length
|
|
697
|
-
* normalisation stops a wordy description from outscoring the tool actually named.
|
|
698
|
-
*
|
|
699
|
-
* `confident` is what a caller acts on, and it is deliberately hard to earn: something more
|
|
700
|
-
* distinctive than a word the whole catalogue shares has to have matched, and the tools left
|
|
701
|
-
* unpicked have to score well below the ones picked. Anything else is ambiguous, and ambiguous is
|
|
702
|
-
* what the model is for. Nothing matching is not confident either — the words cannot tell "this
|
|
703
|
-
* request needs no tools" from "these words are not in the catalogue".
|
|
704
|
-
*
|
|
705
|
-
* @param catalog The servers to choose from. Each tool is matched on its name, its server's label
|
|
706
|
-
* and its one-line description, which is everything the catalogue holds.
|
|
707
|
-
* @param prompt The request being planned for. Only its head is read, as in `preselectInput`.
|
|
708
|
-
* @param options The cap, the two confidence thresholds, and where the request is cut.
|
|
709
|
-
*/
|
|
710
|
-
export function preselectByKeywords(catalog, prompt, { maxPerLoad = MAX_PER_LOAD, minScore = KEYWORD_MIN_SCORE, dropoff = KEYWORD_DROPOFF, maxPromptChars = PRESELECT_PROMPT_CHARS, } = {}) {
|
|
711
|
-
const empty = { names: [], confident: false, ranked: [] };
|
|
712
|
-
const docs = catalog.flatMap((server) => server.tools.map((tool) => ({
|
|
713
|
-
name: tool.name,
|
|
714
|
-
terms: terms(`${tool.name} ${server.label} ${tool.description}`),
|
|
715
|
-
})));
|
|
716
|
-
// A query term repeated in the request is not worth more than one said once: the request is
|
|
717
|
-
// prose about a task, not a document being matched against another document.
|
|
718
|
-
const query = new Set(terms(prompt.slice(0, maxPromptChars)));
|
|
719
|
-
if (!docs.length || !query.size)
|
|
720
|
-
return empty;
|
|
721
|
-
const length = docs.reduce((total, doc) => total + doc.terms.length, 0) / docs.length;
|
|
722
|
-
const documents = new Map();
|
|
723
|
-
for (const doc of docs)
|
|
724
|
-
for (const term of new Set(doc.terms))
|
|
725
|
-
documents.set(term, (documents.get(term) ?? 0) + 1);
|
|
726
|
-
const ranked = docs
|
|
727
|
-
.map((doc) => {
|
|
728
|
-
const counts = new Map();
|
|
729
|
-
for (const term of doc.terms)
|
|
730
|
-
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
731
|
-
let score = 0;
|
|
732
|
-
for (const term of query) {
|
|
733
|
-
const found = counts.get(term);
|
|
734
|
-
if (!found)
|
|
735
|
-
continue;
|
|
736
|
-
const held = documents.get(term) ?? 0;
|
|
737
|
-
const idf = Math.log(1 + (docs.length - held + 0.5) / (held + 0.5));
|
|
738
|
-
const norm = BM25_K1 * (1 - BM25_B + (BM25_B * doc.terms.length) / length);
|
|
739
|
-
score += (idf * found * (BM25_K1 + 1)) / (found + norm);
|
|
740
|
-
}
|
|
741
|
-
return { name: doc.name, score };
|
|
742
|
-
})
|
|
743
|
-
.filter((hit) => hit.score > 0)
|
|
744
|
-
// Ties break on the name, not on where the tool sat in the catalogue, so reconnecting a
|
|
745
|
-
// server in a different order does not change what a run opens with.
|
|
746
|
-
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
747
|
-
if (!ranked.length)
|
|
748
|
-
return empty;
|
|
749
|
-
const names = ranked.slice(0, maxPerLoad).map((hit) => hit.name);
|
|
750
|
-
const cut = ranked[names.length - 1].score;
|
|
751
|
-
const next = ranked[maxPerLoad]?.score ?? 0;
|
|
752
|
-
return { names, confident: ranked[0].score >= minScore && next <= dropoff * cut, ranked };
|
|
753
|
-
}
|