efaimo 0.1.2 → 0.2.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/CHANGELOG.md +63 -1
- package/README.md +68 -6
- package/SKILL.md +14 -7
- package/dist/cli.js +162 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +121 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +120 -0
- package/dist/find/find.js +403 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +13 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +12 -1
- package/dist/reporters/pretty.js +174 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +32 -1
- package/dist/testing/harness.js +28 -3
- package/dist/testing/stats.d.ts +16 -0
- package/dist/testing/stats.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +110 -6
- package/docs/RULES.md +40 -3
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Bm25Index, DEFAULT_BM25, scoresTie } from "./bm25.js";
|
|
2
|
+
import { nameIsGeneric, QUERY_STOPWORDS, tokenize } from "./tokenize.js";
|
|
3
|
+
// QUERY_STOPWORDS is used twice here on purpose: a word nobody would type is
|
|
4
|
+
// neither a query term nor vocabulary that makes a tool findable.
|
|
5
|
+
/**
|
|
6
|
+
* Findability: can a search over this catalog pick this tool out at all?
|
|
7
|
+
*
|
|
8
|
+
* Why this exists. Since 2026-01 a host can mark tools `defer_loading: true`,
|
|
9
|
+
* which keeps them out of the system-prompt prefix entirely; the model then
|
|
10
|
+
* finds them by searching the catalog, and Anthropic recommends turning that
|
|
11
|
+
* on once tool definitions pass ~10k tokens. Under deferral a tool no search
|
|
12
|
+
* surfaces costs zero tokens and provides zero capability, and no existing
|
|
13
|
+
* check looks at that: `weigh` measures what a tool costs, `check` measures
|
|
14
|
+
* what a model experiences once the tool is already in front of it.
|
|
15
|
+
*
|
|
16
|
+
* Two measurements, and they are not the same kind of thing.
|
|
17
|
+
*
|
|
18
|
+
* 1. EXCLUSIVE VOCABULARY, the headline. Which terms does a tool have that no
|
|
19
|
+
* other tool in the catalog has? A tool with none cannot be matched by any
|
|
20
|
+
* query that does not also match a competitor, because every word it
|
|
21
|
+
* contains is somewhere else too. That is a fact about the catalog, checked
|
|
22
|
+
* by inspection, with no model of how anyone searches, no parameters, and
|
|
23
|
+
* nothing to disagree with except the tokenizer.
|
|
24
|
+
*
|
|
25
|
+
* It also discriminates, which the alternative did not. Measured on
|
|
26
|
+
* `@playwright/mcp` 0.0.78: 22 of 24 tools own a word, and `browser_close`
|
|
27
|
+
* and `browser_navigate` own none.
|
|
28
|
+
*
|
|
29
|
+
* 2. THE SEARCH PROBE, secondary. Simulated BM25 over the four fields tool
|
|
30
|
+
* search documents as searchable, each tool queried with the top terms of
|
|
31
|
+
* its own description. This is a model, and a generous one: the query comes
|
|
32
|
+
* from the tool's own words. It reads 100% on both the official reference
|
|
33
|
+
* server and playwright, so it is a floor test that catches catastrophe -
|
|
34
|
+
* an empty description, a literal duplicate - and not a ranking. The report
|
|
35
|
+
* says so on every run rather than letting a saturated number look like a
|
|
36
|
+
* good score.
|
|
37
|
+
*
|
|
38
|
+
* Neither is a grade. See ADR-030 for why this ships as proportions.
|
|
39
|
+
*/
|
|
40
|
+
/** How many of a tool's own terms make up its self-query. */
|
|
41
|
+
export const DEFAULT_QUERY_TERMS = 4;
|
|
42
|
+
/**
|
|
43
|
+
* How many results a search returns. Anthropic documents a default of 5 (the
|
|
44
|
+
* model may raise it), so 5 is what a tool has to land inside to be seen
|
|
45
|
+
* without the model deciding to look harder.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_TOP_K = 5;
|
|
48
|
+
/**
|
|
49
|
+
* When Anthropic's own guidance says to turn tool search on.
|
|
50
|
+
*
|
|
51
|
+
* The doc lists five conditions joined by "any of the following apply", and
|
|
52
|
+
* two of them can be checked from a tool list: "You have 10 or more tools
|
|
53
|
+
* available" and "Your tool definitions consume more than 10k tokens". The
|
|
54
|
+
* other three are about the operator's situation, not the catalog.
|
|
55
|
+
*
|
|
56
|
+
* Both are checked. An earlier version implemented only the token clause,
|
|
57
|
+
* which told a reader that a 24-tool, 3.5k-token server was "probably loaded
|
|
58
|
+
* up front today" when Anthropic's first condition already applied to it, and
|
|
59
|
+
* left E145 structurally unable to fire on the most common shape there is: a
|
|
60
|
+
* catalog with plenty of tools and modest descriptions.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFER_RECOMMENDED_TOKENS = 10_000;
|
|
63
|
+
export const DEFER_RECOMMENDED_TOOLS = 10;
|
|
64
|
+
/**
|
|
65
|
+
* Bounded walk of a JSON Schema, collecting argument names and descriptions.
|
|
66
|
+
*
|
|
67
|
+
* Two things this got wrong and now does not.
|
|
68
|
+
*
|
|
69
|
+
* It used `out.push(...tokenize(text))`, which passes every token as a separate
|
|
70
|
+
* argument. One argument description long enough to produce ~125,000 tokens
|
|
71
|
+
* (about a megabyte, entirely within reach of an untrusted third-party server)
|
|
72
|
+
* blew the call-stack limit and took the whole run down with a `RangeError`.
|
|
73
|
+
* Tokens are appended one at a time now, and there is a token budget as well
|
|
74
|
+
* as a node budget: bounding node COUNT does not bound the work one node can do.
|
|
75
|
+
*
|
|
76
|
+
* And it walked only `properties` and a singular `items`, which misses the two
|
|
77
|
+
* shapes generated schemas actually take: pydantic hoists everything into
|
|
78
|
+
* `$defs` and points at it with `$ref`, and zod emits `anyOf`/`oneOf`. Against
|
|
79
|
+
* those, argument names and descriptions went uncollected, which makes
|
|
80
|
+
* `distinct` PESSIMISTIC: a tool that really does own a word gets reported as
|
|
81
|
+
* owning none and E141 fires on a server that is fine. `$defs` is walked
|
|
82
|
+
* directly rather than resolving `$ref`, because the text is what is wanted,
|
|
83
|
+
* not the structure, and a walk cannot loop that way.
|
|
84
|
+
*/
|
|
85
|
+
function schemaTerms(schema, depth = 0, budget = { nodes: 600, tokens: 20_000 }) {
|
|
86
|
+
const out = [];
|
|
87
|
+
if (depth > 4 || budget.nodes <= 0 || budget.tokens <= 0 || !schema || typeof schema !== "object")
|
|
88
|
+
return out;
|
|
89
|
+
budget.nodes--;
|
|
90
|
+
const node = schema;
|
|
91
|
+
const take = (text) => {
|
|
92
|
+
if (typeof text !== "string")
|
|
93
|
+
return;
|
|
94
|
+
for (const t of tokenize(text)) {
|
|
95
|
+
if (budget.tokens-- <= 0)
|
|
96
|
+
return;
|
|
97
|
+
out.push(t);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const descend = (child) => {
|
|
101
|
+
for (const t of schemaTerms(child, depth + 1, budget)) {
|
|
102
|
+
if (budget.tokens-- <= 0)
|
|
103
|
+
return;
|
|
104
|
+
out.push(t);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
for (const key of ["properties", "$defs", "definitions", "patternProperties"]) {
|
|
108
|
+
const bag = node[key];
|
|
109
|
+
if (!bag || typeof bag !== "object" || Array.isArray(bag))
|
|
110
|
+
continue;
|
|
111
|
+
for (const [name, value] of Object.entries(bag)) {
|
|
112
|
+
if (budget.nodes <= 0 || budget.tokens <= 0)
|
|
113
|
+
break;
|
|
114
|
+
// Only `properties` and `patternProperties` keys are argument names a
|
|
115
|
+
// caller types; a `$defs` key is a type name the model never sees.
|
|
116
|
+
if (key === "properties" || key === "patternProperties")
|
|
117
|
+
take(name);
|
|
118
|
+
if (value && typeof value === "object") {
|
|
119
|
+
// Description only. A property `title` is no more a searchable field
|
|
120
|
+
// than a tool `title` is, and indexing it would be the same mistake
|
|
121
|
+
// one level down.
|
|
122
|
+
take(value.description);
|
|
123
|
+
descend(value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const key of ["items", "additionalProperties", "not", "contains"]) {
|
|
128
|
+
const child = node[key];
|
|
129
|
+
// A tuple schema writes `items` as an ARRAY; that shape is handled below,
|
|
130
|
+
// and descending into the array itself would spend a node on nothing.
|
|
131
|
+
if (child && typeof child === "object" && !Array.isArray(child))
|
|
132
|
+
descend(child);
|
|
133
|
+
}
|
|
134
|
+
for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
|
|
135
|
+
const arr = node[key];
|
|
136
|
+
if (!Array.isArray(arr))
|
|
137
|
+
continue;
|
|
138
|
+
for (const child of arr) {
|
|
139
|
+
if (budget.nodes <= 0 || budget.tokens <= 0)
|
|
140
|
+
break;
|
|
141
|
+
descend(child);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// A tuple schema writes `items: [ {...}, {...} ]`, which the object branch
|
|
145
|
+
// above skips because an array is typeof "object" but has no keys of interest.
|
|
146
|
+
if (Array.isArray(node.items)) {
|
|
147
|
+
for (const child of node.items) {
|
|
148
|
+
if (budget.nodes <= 0 || budget.tokens <= 0)
|
|
149
|
+
break;
|
|
150
|
+
descend(child);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The four fields tool search matches on: name, description, argument names,
|
|
157
|
+
* argument descriptions. Kept in one function so the index and the docs can
|
|
158
|
+
* never disagree about what is searched.
|
|
159
|
+
*
|
|
160
|
+
* `title` is deliberately NOT among them, for two reasons that point the same
|
|
161
|
+
* way. Anthropic's list of searchable fields is these four and does not
|
|
162
|
+
* include it. And MCP `title` is a human-readable restatement of the name:
|
|
163
|
+
* every one of the reference server's thirteen tools sets one, and every one
|
|
164
|
+
* is the name title-cased ("Echo Tool", "Get Annotated Message Tool"). An
|
|
165
|
+
* earlier version indexed and PROBED with it, which quietly put the tool's own
|
|
166
|
+
* name back into its own query through the back door and undid the whole
|
|
167
|
+
* reason the name is excluded. Two tools with identical descriptions and
|
|
168
|
+
* name-derived titles scored a clean rank 1 each instead of tying.
|
|
169
|
+
*
|
|
170
|
+
* Indexing a field the real search does not read would also make `distinct`
|
|
171
|
+
* optimistic: a tool could "own" a word that exists only in its title, where
|
|
172
|
+
* no query can reach it.
|
|
173
|
+
*/
|
|
174
|
+
export function indexedTerms(tool) {
|
|
175
|
+
return [
|
|
176
|
+
...tokenize(tool.name),
|
|
177
|
+
...tokenize(tool.description ?? ""),
|
|
178
|
+
...schemaTerms(tool.inputSchema),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The terms a person would plausibly type to reach this tool.
|
|
183
|
+
*
|
|
184
|
+
* Drawn from the description, and deliberately NOT from the name, the title,
|
|
185
|
+
* or the argument names.
|
|
186
|
+
*
|
|
187
|
+
* Not the name, because the person searching does not know it. That is the
|
|
188
|
+
* whole situation being modelled: the tool is deferred, the user describes a
|
|
189
|
+
* task, and the model turns that description into a query. An earlier version
|
|
190
|
+
* of this drew query terms from the name too, and the result was a metric that
|
|
191
|
+
* could barely fail - a unique name token has maximal idf, so it entered every
|
|
192
|
+
* query and pulled its own tool to the top. Two tools with the same
|
|
193
|
+
* description and different names both scored a clean rank 1, which is exactly
|
|
194
|
+
* backwards: identical descriptions are the case where a searcher cannot tell
|
|
195
|
+
* them apart. The name stays in the INDEX, so a name whose words echo the
|
|
196
|
+
* description still helps the tool rank.
|
|
197
|
+
*
|
|
198
|
+
* Not the title either, and that one is the same bug wearing a different hat:
|
|
199
|
+
* MCP `title` is usually the name title-cased, so reading it here put the name
|
|
200
|
+
* back into its own query after all the trouble taken to keep it out. See
|
|
201
|
+
* `indexedTerms`, which drops it from the index for the same reason.
|
|
202
|
+
*
|
|
203
|
+
* Not argument names, because a user describes a task ("post a message to
|
|
204
|
+
* slack"), not a parameter list. They are indexed, so a search can still reach
|
|
205
|
+
* them; they are just never what the tool is probed with.
|
|
206
|
+
*
|
|
207
|
+
* Ranked by tf*idf inside this catalog, so the terms chosen are the ones that
|
|
208
|
+
* separate this tool from its siblings, with stopwords excluded because in a
|
|
209
|
+
* small catalog a function word repeated in one description can outrank the
|
|
210
|
+
* noun that identifies the tool.
|
|
211
|
+
*/
|
|
212
|
+
export function taskQuery(tool, index, limit = DEFAULT_QUERY_TERMS) {
|
|
213
|
+
const source = tokenize(tool.description ?? "");
|
|
214
|
+
const tf = new Map();
|
|
215
|
+
for (const t of source) {
|
|
216
|
+
if (QUERY_STOPWORDS.has(t))
|
|
217
|
+
continue;
|
|
218
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
return [...tf.entries()]
|
|
221
|
+
.map(([term, count]) => ({ term, weight: count * index.idf(term) }))
|
|
222
|
+
// Code-point tie-break, not localeCompare: the query has to be the same on
|
|
223
|
+
// every machine or the whole report is unreproducible.
|
|
224
|
+
.sort((a, b) => (b.weight - a.weight) || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0))
|
|
225
|
+
.slice(0, limit)
|
|
226
|
+
.map((x) => x.term);
|
|
227
|
+
}
|
|
228
|
+
export function analyzeFind(label, tools, opts = {}) {
|
|
229
|
+
const topK = opts.topK ?? DEFAULT_TOP_K;
|
|
230
|
+
const queryTerms = opts.queryTerms ?? DEFAULT_QUERY_TERMS;
|
|
231
|
+
// An empty catalog is a failure, never a clean report. `find` on a server
|
|
232
|
+
// that exposes no tools would otherwise print a perfect 0/0 = 100% reach,
|
|
233
|
+
// which is the project's documented dominant failure mode: a green that
|
|
234
|
+
// examined nothing. The caller turns this into an exit code.
|
|
235
|
+
if (!tools.length) {
|
|
236
|
+
throw new Error(`"${label}" exposes no tools, so there is nothing to search for. ` +
|
|
237
|
+
`An empty catalog is not a findable one; check that the server started and listed its tools.`);
|
|
238
|
+
}
|
|
239
|
+
const docs = tools.map((t) => ({ id: t.name, terms: indexedTerms(t) }));
|
|
240
|
+
const index = new Bm25Index(docs, DEFAULT_BM25);
|
|
241
|
+
// Exclusive vocabulary. Computed from the document frequencies the index
|
|
242
|
+
// already holds, so the two measurements cannot disagree about what a term
|
|
243
|
+
// is: one tokenizer, one set of documents.
|
|
244
|
+
const termSets = docs.map((d) => new Set(d.terms));
|
|
245
|
+
const nameTermSets = tools.map((t) => new Set(tokenize(t.name)));
|
|
246
|
+
/** How many "who shares my vocabulary" scans to run. See the comment at its use. */
|
|
247
|
+
const SHARED_WITH_BUDGET = 200;
|
|
248
|
+
let sharedComputed = 0;
|
|
249
|
+
const perTool = tools.map((tool, i) => {
|
|
250
|
+
// Exclusive terms, with function words removed.
|
|
251
|
+
//
|
|
252
|
+
// A tool can be the only one in a catalog that happens to say "the", and
|
|
253
|
+
// counting that as vocabulary it owns would be true and useless: nobody
|
|
254
|
+
// searches for it. Function words are already excluded from the queries
|
|
255
|
+
// this file builds, for the same reason, and letting them count here would
|
|
256
|
+
// have let a catalog of near-identical tools score a clean 100%.
|
|
257
|
+
//
|
|
258
|
+
// Most-used first, then alphabetical. The list is capped for display, and
|
|
259
|
+
// an alphabetical cap shows whichever exclusive words happen to start with
|
|
260
|
+
// an early letter rather than the ones the tool is actually about: on
|
|
261
|
+
// `get_weather` that hid `weather` behind `city, conditions, current`.
|
|
262
|
+
const own = [...termSets[i]]
|
|
263
|
+
.filter((t) => index.documentFrequency(t) === 1 && !QUERY_STOPWORDS.has(t))
|
|
264
|
+
.sort((a, b) => index.termFrequency(i, b) - index.termFrequency(i, a) || (a < b ? -1 : a > b ? 1 : 0));
|
|
265
|
+
// How much of that survives if the searcher does not know the name. A tool
|
|
266
|
+
// whose only exclusive word IS its name is reachable in principle (names
|
|
267
|
+
// are searchable) and unreachable in practice by anyone describing a task.
|
|
268
|
+
const ownOutsideName = own.filter((t) => !nameTermSets[i].has(t));
|
|
269
|
+
// Who shares this tool's vocabulary when it owns nothing. Ranked by how
|
|
270
|
+
// much of it they cover, so the first name listed is the tool most worth
|
|
271
|
+
// comparing against.
|
|
272
|
+
// Only for tools that own nothing, and only for the first few of those.
|
|
273
|
+
//
|
|
274
|
+
// This is the one quadratic step here, and it used to re-materialise
|
|
275
|
+
// `[...termSets[i]]` as a fresh array inside the loop, once per sibling:
|
|
276
|
+
// 1,000 tools that all share their vocabulary took about four seconds and
|
|
277
|
+
// 10,000 extrapolated to over half an hour. Iterating the smaller set with
|
|
278
|
+
// no allocation removes most of that, and the cap removes the rest. The
|
|
279
|
+
// cap costs nothing visible: the rules report at most five of these and
|
|
280
|
+
// the table shows fifteen rows.
|
|
281
|
+
const sharedWith = own.length || sharedComputed >= SHARED_WITH_BUDGET ? [] : (() => {
|
|
282
|
+
sharedComputed++;
|
|
283
|
+
const mine = termSets[i];
|
|
284
|
+
const scored = [];
|
|
285
|
+
for (let j = 0; j < tools.length; j++) {
|
|
286
|
+
if (j === i)
|
|
287
|
+
continue;
|
|
288
|
+
const other = termSets[j];
|
|
289
|
+
const [small, big] = mine.size <= other.size ? [mine, other] : [other, mine];
|
|
290
|
+
let overlap = 0;
|
|
291
|
+
for (const t of small)
|
|
292
|
+
if (big.has(t))
|
|
293
|
+
overlap++;
|
|
294
|
+
if (overlap > 0)
|
|
295
|
+
scored.push({ name: tools[j].name, overlap });
|
|
296
|
+
}
|
|
297
|
+
return scored
|
|
298
|
+
.sort((a, b) => b.overlap - a.overlap || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
299
|
+
.slice(0, 3)
|
|
300
|
+
.map((x) => x.name);
|
|
301
|
+
})();
|
|
302
|
+
const query = taskQuery(tool, index, queryTerms);
|
|
303
|
+
const hits = index.score(query);
|
|
304
|
+
// By POSITION, not by name. Two tools may share a name, and matching on it
|
|
305
|
+
// handed every duplicate the better twin's rank, score and tie set.
|
|
306
|
+
const at = hits.findIndex((h) => h.index === i);
|
|
307
|
+
const rank = at >= 0 ? at + 1 : undefined;
|
|
308
|
+
const score = at >= 0 ? hits[at].score : 0;
|
|
309
|
+
const outrankedBy = at > 0 ? hits.slice(0, at).map((h) => h.id) : [];
|
|
310
|
+
const tied = at >= 0
|
|
311
|
+
? hits.filter((h, pos) => pos !== at && scoresTie(h.score, score) && h.score > 0).map((h) => h.id)
|
|
312
|
+
: [];
|
|
313
|
+
return {
|
|
314
|
+
name: tool.name,
|
|
315
|
+
ownTerms: own.slice(0, 4),
|
|
316
|
+
ownTermCount: own.length,
|
|
317
|
+
ownOutsideNameCount: ownOutsideName.length,
|
|
318
|
+
sharedWith,
|
|
319
|
+
query,
|
|
320
|
+
rank,
|
|
321
|
+
score,
|
|
322
|
+
outrankedBy: outrankedBy.slice(0, 3),
|
|
323
|
+
tiedWith: tied.slice(0, 3),
|
|
324
|
+
// Kept separately from the display cap. The rule that counts pairs was
|
|
325
|
+
// counting the capped list, so a catalog where every tool ties with
|
|
326
|
+
// every other reported 30 pairs instead of the real 66: a count of what
|
|
327
|
+
// survived a display cap, presented as a count of the problem.
|
|
328
|
+
tiedWithCount: tied.length,
|
|
329
|
+
reachable: rank !== undefined && rank <= topK,
|
|
330
|
+
genericName: nameIsGeneric(tool.name),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
const distinctCount = perTool.filter((t) => t.ownTermCount > 0).length;
|
|
334
|
+
const reachable = perTool.filter((t) => t.reachable).length;
|
|
335
|
+
const windowCoversCatalog = tools.length <= topK;
|
|
336
|
+
const distinctVacuous = tools.length < 2;
|
|
337
|
+
const deferBecause = [];
|
|
338
|
+
if (tools.length >= DEFER_RECOMMENDED_TOOLS) {
|
|
339
|
+
deferBecause.push(`${tools.length} tools (Anthropic recommends tool search at ${DEFER_RECOMMENDED_TOOLS} or more)`);
|
|
340
|
+
}
|
|
341
|
+
if ((opts.definitionTokens ?? 0) > DEFER_RECOMMENDED_TOKENS) {
|
|
342
|
+
deferBecause.push(`~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions (Anthropic recommends tool search over ${DEFER_RECOMMENDED_TOKENS.toLocaleString("en-US")})`);
|
|
343
|
+
}
|
|
344
|
+
const deferRecommended = deferBecause.length > 0;
|
|
345
|
+
const notes = [
|
|
346
|
+
`method: terms come from the four fields tool search documents as searchable (tool name, description, argument names, argument descriptions). ` +
|
|
347
|
+
`"distinct" is a property of the catalog, not a model. The probe is a simulated BM25 (k1=${DEFAULT_BM25.k1}, b=${DEFAULT_BM25.b}), ` +
|
|
348
|
+
`each tool queried with the ${queryTerms} highest tf-idf terms of its own description (never its name or title, which a searcher does not know), window ${topK}. docs/METHODOLOGY.md.`,
|
|
349
|
+
`the probe reads 100% on every real server measured so far, so treat it as a floor test that catches an empty or duplicated description, not as a ranking.`,
|
|
350
|
+
];
|
|
351
|
+
// Vacuity is stated, not hidden, and there are TWO kinds of it.
|
|
352
|
+
//
|
|
353
|
+
// The probe goes vacuous whenever the catalog fits inside the result window.
|
|
354
|
+
// `distinct` goes vacuous only at one tool, where every term trivially has
|
|
355
|
+
// document frequency 1 and the figure is 100% for any catalog whatsoever.
|
|
356
|
+
// The first version of this said "exclusive vocabulary can fail at any
|
|
357
|
+
// catalog size, including two tools", which is true at two and false at one,
|
|
358
|
+
// and then reassured the reader that everything except the probe was "still
|
|
359
|
+
// meaningful" while the headline number was the one that had stopped being
|
|
360
|
+
// evidence. One-tool servers are real: sequential-thinking is one.
|
|
361
|
+
if (windowCoversCatalog) {
|
|
362
|
+
notes.push(`VACUOUS PROBE: ${tools.length} tools and a result window of ${topK}, so no tool can fall outside it. ` +
|
|
363
|
+
`The probe figure is 100% by construction. Ranks, exclusive vocabulary and the findings below are still evidence.`);
|
|
364
|
+
}
|
|
365
|
+
if (distinctVacuous) {
|
|
366
|
+
notes.push(`VACUOUS DISTINCT: a one-tool catalog has nothing to be distinct from, so every term it has is trivially exclusive ` +
|
|
367
|
+
`and the figure is 100% for any tool whatsoever. It is not evidence about this server, and --min-distinct cannot be evaluated here.`);
|
|
368
|
+
}
|
|
369
|
+
// Duplicate names are a real defect and nothing else here reports them. The
|
|
370
|
+
// ranking no longer confuses two tools that share a name, but a client that
|
|
371
|
+
// routes by name still will.
|
|
372
|
+
const dupNames = [...new Set(tools.map((t) => t.name).filter((nm, i, all) => all.indexOf(nm) !== i))];
|
|
373
|
+
if (dupNames.length) {
|
|
374
|
+
notes.push(`${dupNames.length} tool name${dupNames.length === 1 ? " is" : "s are"} used by more than one tool (${dupNames.slice(0, 3).join(", ")}). ` +
|
|
375
|
+
`Ranks here are per tool, but a client that addresses tools by name cannot tell them apart at all.`);
|
|
376
|
+
}
|
|
377
|
+
notes.push(deferRecommended
|
|
378
|
+
? `tool search is recommended for this catalog (${deferBecause.join("; ")}), so assume these tools are deferred and that findability is what decides whether they get used.`
|
|
379
|
+
: `${tools.length} tools and ~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions, under both thresholds where Anthropic recommends tool search, so these tools are probably loaded up front today and this report is advisory rather than urgent.`);
|
|
380
|
+
return {
|
|
381
|
+
kind: "find",
|
|
382
|
+
label,
|
|
383
|
+
toolCount: tools.length,
|
|
384
|
+
method: { tokenizer: "efaimo-v1", bm25: { ...DEFAULT_BM25 }, queryTerms, topK },
|
|
385
|
+
perTool,
|
|
386
|
+
distinct: {
|
|
387
|
+
count: distinctCount,
|
|
388
|
+
total: tools.length,
|
|
389
|
+
pct: Math.round((distinctCount / tools.length) * 1000) / 10,
|
|
390
|
+
},
|
|
391
|
+
probe: {
|
|
392
|
+
returned: reachable,
|
|
393
|
+
total: tools.length,
|
|
394
|
+
pct: Math.round((reachable / tools.length) * 1000) / 10,
|
|
395
|
+
},
|
|
396
|
+
windowCoversCatalog,
|
|
397
|
+
distinctVacuous,
|
|
398
|
+
definitionTokens: opts.definitionTokens,
|
|
399
|
+
deferRecommended,
|
|
400
|
+
deferBecause,
|
|
401
|
+
notes,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tokenizer the findability simulation runs on.
|
|
3
|
+
*
|
|
4
|
+
* Tool search (both variants) matches against a tool's name, description,
|
|
5
|
+
* argument names and argument descriptions. Anthropic does not publish the
|
|
6
|
+
* analyzer it uses, so this is a stated approximation rather than a
|
|
7
|
+
* reproduction, and docs/METHODOLOGY.md says exactly that. What matters is
|
|
8
|
+
* that it is deterministic and written down: the same catalog always produces
|
|
9
|
+
* the same terms, on every machine.
|
|
10
|
+
*
|
|
11
|
+
* Splitting rules, in order:
|
|
12
|
+
* 1. camelCase and PascalCase boundaries become separators (`getWeather` ->
|
|
13
|
+
* `get weather`), because a regex like `.*weather.*` has to reach the
|
|
14
|
+
* second half of a camelCase name and a naive split on `[^a-z0-9]` never
|
|
15
|
+
* would.
|
|
16
|
+
* 2. Runs of digits after letters split too (`s3Bucket` keeps `s3`, but
|
|
17
|
+
* `getV2Config` yields `get v2 config`).
|
|
18
|
+
* 3. Everything that is not a letter or a digit is a separator, so
|
|
19
|
+
* `get_weather`, `get-weather` and `get.weather` all land on the same
|
|
20
|
+
* two terms.
|
|
21
|
+
* 4. Lowercase, and drop single characters, which carry no retrieval signal
|
|
22
|
+
* and only distort the length normalization.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Longest field this will tokenize.
|
|
26
|
+
*
|
|
27
|
+
* Every string here comes from the audited server, which may be hostile and is
|
|
28
|
+
* in any case not obliged to be sensible. The split below is linear, but linear
|
|
29
|
+
* on ten megabytes is still ten megabytes, done twice per description, and the
|
|
30
|
+
* connect timeout bounds the RPC rather than what happens after it. A tool
|
|
31
|
+
* definition that needs more than this to describe itself has a finding of its
|
|
32
|
+
* own waiting in `check`.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MAX_FIELD_CHARS = 200000;
|
|
35
|
+
export declare function tokenize(text: string): string[];
|
|
36
|
+
/**
|
|
37
|
+
* English function words, excluded from QUERY SELECTION only.
|
|
38
|
+
*
|
|
39
|
+
* They stay in the index on purpose. BM25's IDF already discounts a term that
|
|
40
|
+
* appears in most documents, and removing them from the index would also
|
|
41
|
+
* change every document's length, which changes every score. They are excluded
|
|
42
|
+
* from query selection because that step ranks a tool's own terms by TF-IDF,
|
|
43
|
+
* and in a small catalog a function word repeated three times in one
|
|
44
|
+
* description can outrank the domain noun that actually identifies the tool.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately short and boring: this list is part of the published method, so
|
|
47
|
+
* every word in it has to be defensible as a word nobody searches for.
|
|
48
|
+
*/
|
|
49
|
+
export declare const QUERY_STOPWORDS: Set<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Name tokens that identify nothing on their own.
|
|
52
|
+
*
|
|
53
|
+
* Used by E144 only. Anthropic's own debugging note for tool search says
|
|
54
|
+
* Claude writes broad patterns such as `.*weather.*`, and that adding common
|
|
55
|
+
* keywords to descriptions improves discoverability. A name assembled purely
|
|
56
|
+
* from these words carries no such keyword, so the only thing that can reach
|
|
57
|
+
* the tool is its description.
|
|
58
|
+
*
|
|
59
|
+
* A word being here does NOT make a name bad: `get_weather` has `weather`.
|
|
60
|
+
* The rule fires only when EVERY token of a name is in this set.
|
|
61
|
+
*/
|
|
62
|
+
export declare const GENERIC_NAME_TOKENS: Set<string>;
|
|
63
|
+
/** True when a tool name is built only from words that identify nothing. */
|
|
64
|
+
export declare function nameIsGeneric(name: string): boolean;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tokenizer the findability simulation runs on.
|
|
3
|
+
*
|
|
4
|
+
* Tool search (both variants) matches against a tool's name, description,
|
|
5
|
+
* argument names and argument descriptions. Anthropic does not publish the
|
|
6
|
+
* analyzer it uses, so this is a stated approximation rather than a
|
|
7
|
+
* reproduction, and docs/METHODOLOGY.md says exactly that. What matters is
|
|
8
|
+
* that it is deterministic and written down: the same catalog always produces
|
|
9
|
+
* the same terms, on every machine.
|
|
10
|
+
*
|
|
11
|
+
* Splitting rules, in order:
|
|
12
|
+
* 1. camelCase and PascalCase boundaries become separators (`getWeather` ->
|
|
13
|
+
* `get weather`), because a regex like `.*weather.*` has to reach the
|
|
14
|
+
* second half of a camelCase name and a naive split on `[^a-z0-9]` never
|
|
15
|
+
* would.
|
|
16
|
+
* 2. Runs of digits after letters split too (`s3Bucket` keeps `s3`, but
|
|
17
|
+
* `getV2Config` yields `get v2 config`).
|
|
18
|
+
* 3. Everything that is not a letter or a digit is a separator, so
|
|
19
|
+
* `get_weather`, `get-weather` and `get.weather` all land on the same
|
|
20
|
+
* two terms.
|
|
21
|
+
* 4. Lowercase, and drop single characters, which carry no retrieval signal
|
|
22
|
+
* and only distort the length normalization.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Longest field this will tokenize.
|
|
26
|
+
*
|
|
27
|
+
* Every string here comes from the audited server, which may be hostile and is
|
|
28
|
+
* in any case not obliged to be sensible. The split below is linear, but linear
|
|
29
|
+
* on ten megabytes is still ten megabytes, done twice per description, and the
|
|
30
|
+
* connect timeout bounds the RPC rather than what happens after it. A tool
|
|
31
|
+
* definition that needs more than this to describe itself has a finding of its
|
|
32
|
+
* own waiting in `check`.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_FIELD_CHARS = 200_000;
|
|
35
|
+
export function tokenize(text) {
|
|
36
|
+
if (!text)
|
|
37
|
+
return [];
|
|
38
|
+
return (text.length > MAX_FIELD_CHARS ? text.slice(0, MAX_FIELD_CHARS) : text)
|
|
39
|
+
// ACRONYMFollowed -> ACRONYM Followed, before the general camel split, so
|
|
40
|
+
// `HTTPServer` does not become `httpserver` or `h t t p server`.
|
|
41
|
+
//
|
|
42
|
+
// One capital, not `[A-Z]+`. The quantified form is quadratic on a run of
|
|
43
|
+
// capitals with no lowercase after it: every start position backtracks the
|
|
44
|
+
// whole run. Measured on the old expression, `'A'.repeat(n)` took 43ms at
|
|
45
|
+
// 10k, 7.8s at 100k and 147s at 400k, so one 300KB shouted description
|
|
46
|
+
// pinned a core for minutes while `weigh` and `check` on the same server
|
|
47
|
+
// answered in under a second. `([A-Z])([A-Z][a-z])` matches the same
|
|
48
|
+
// boundaries in linear time: on `HTTPServer` it still splits at `P|Se`.
|
|
49
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1 $2")
|
|
50
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
51
|
+
.replace(/([A-Za-z])(\d)/g, "$1 $2")
|
|
52
|
+
.split(/[^A-Za-z0-9]+/)
|
|
53
|
+
.map((s) => s.toLowerCase())
|
|
54
|
+
.filter((s) => s.length > 1);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* English function words, excluded from QUERY SELECTION only.
|
|
58
|
+
*
|
|
59
|
+
* They stay in the index on purpose. BM25's IDF already discounts a term that
|
|
60
|
+
* appears in most documents, and removing them from the index would also
|
|
61
|
+
* change every document's length, which changes every score. They are excluded
|
|
62
|
+
* from query selection because that step ranks a tool's own terms by TF-IDF,
|
|
63
|
+
* and in a small catalog a function word repeated three times in one
|
|
64
|
+
* description can outrank the domain noun that actually identifies the tool.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately short and boring: this list is part of the published method, so
|
|
67
|
+
* every word in it has to be defensible as a word nobody searches for.
|
|
68
|
+
*/
|
|
69
|
+
export const QUERY_STOPWORDS = new Set([
|
|
70
|
+
// Two and three letter function words. The tokenizer drops single
|
|
71
|
+
// characters, so without these a description like "Delete all records in the
|
|
72
|
+
// datastore" probes with `in`, and a term that appears in half the catalog
|
|
73
|
+
// spends one of the four query slots saying nothing.
|
|
74
|
+
"in", "on", "at", "to", "of", "or", "as", "by", "is", "be", "an", "if",
|
|
75
|
+
"do", "so", "up", "we", "us", "am", "he", "she", "him", "her", "his", "me",
|
|
76
|
+
"my", "no", "yes",
|
|
77
|
+
"the", "and", "for", "with", "that", "this", "from", "into", "onto", "than",
|
|
78
|
+
"then", "they", "them", "their", "there", "these", "those", "will", "would",
|
|
79
|
+
"shall", "should", "can", "could", "may", "might", "must", "not", "but",
|
|
80
|
+
"are", "was", "were", "been", "being", "have", "has", "had", "does", "did",
|
|
81
|
+
"you", "your", "yours", "its", "it", "our", "ours", "any", "all", "each",
|
|
82
|
+
"such", "only", "also", "more", "most", "some", "other", "when", "where",
|
|
83
|
+
"which", "while", "what", "who", "whom", "how", "why", "use", "used",
|
|
84
|
+
"using", "via", "per", "out", "off", "over", "under", "about", "above",
|
|
85
|
+
"below", "between", "before", "after", "here", "one", "two", "yes", "no",
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Name tokens that identify nothing on their own.
|
|
89
|
+
*
|
|
90
|
+
* Used by E144 only. Anthropic's own debugging note for tool search says
|
|
91
|
+
* Claude writes broad patterns such as `.*weather.*`, and that adding common
|
|
92
|
+
* keywords to descriptions improves discoverability. A name assembled purely
|
|
93
|
+
* from these words carries no such keyword, so the only thing that can reach
|
|
94
|
+
* the tool is its description.
|
|
95
|
+
*
|
|
96
|
+
* A word being here does NOT make a name bad: `get_weather` has `weather`.
|
|
97
|
+
* The rule fires only when EVERY token of a name is in this set.
|
|
98
|
+
*/
|
|
99
|
+
export const GENERIC_NAME_TOKENS = new Set([
|
|
100
|
+
"get", "set", "put", "post", "patch", "add", "new", "create", "make",
|
|
101
|
+
"update", "edit", "modify", "change", "delete", "remove", "drop",
|
|
102
|
+
"list", "read", "write", "fetch", "load", "save", "send", "call",
|
|
103
|
+
"run", "exec", "execute", "invoke", "do", "handle", "process", "perform",
|
|
104
|
+
"query", "search", "find", "lookup", "check", "test", "start", "stop",
|
|
105
|
+
"open", "close", "begin", "end", "init", "setup", "config", "configure",
|
|
106
|
+
"tool", "api", "data", "item", "items", "object", "thing", "value",
|
|
107
|
+
"values", "result", "results", "action", "request", "response", "info",
|
|
108
|
+
"helper", "util", "utils", "main", "default", "generic", "custom", "my",
|
|
109
|
+
]);
|
|
110
|
+
/** True when a tool name is built only from words that identify nothing. */
|
|
111
|
+
export function nameIsGeneric(name) {
|
|
112
|
+
const terms = tokenize(name);
|
|
113
|
+
if (!terms.length)
|
|
114
|
+
return true;
|
|
115
|
+
return terms.every((t) => GENERIC_NAME_TOKENS.has(t));
|
|
116
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,10 +7,13 @@ export { weighServer, weighSkills } from "./weigh/weigh.js";
|
|
|
7
7
|
export { diffServerWeigh, type WeighDiff } from "./weigh/diff.js";
|
|
8
8
|
export { countTokens } from "./weigh/tokens.js";
|
|
9
9
|
export { findSkills, parseSkillFile } from "./skills/parse.js";
|
|
10
|
+
export { analyzeFind, indexedTerms, taskQuery, DEFAULT_TOP_K, DEFAULT_QUERY_TERMS, DEFER_RECOMMENDED_TOKENS, DEFER_RECOMMENDED_TOOLS, type FindOptions } from "./find/find.js";
|
|
11
|
+
export { runFindRules } from "./core/engine.js";
|
|
12
|
+
export { RULES_VERSION } from "./rules/version.js";
|
|
10
13
|
export { checkMcpTarget, checkMcpRepoOnly, checkSkillSet, type SkillReport, type CheckSkillResult } from "./check/check.js";
|
|
11
14
|
export { parseScenario, runScenario, armSystems, type Scenario, type TestReport, type Runner, } from "./testing/harness.js";
|
|
12
|
-
export { anthropicRunner } from "./testing/anthropicRunner.js";
|
|
15
|
+
export { anthropicRunner, acceptsSamplingParams, anthropicRequestBody } from "./testing/anthropicRunner.js";
|
|
13
16
|
export { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
|
|
14
|
-
export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, } from "./reporters/pretty.js";
|
|
15
|
-
export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, } from "./reporters/markdown.js";
|
|
17
|
+
export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, renderFindPretty, renderFindFindingsPretty, } from "./reporters/pretty.js";
|
|
18
|
+
export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, renderFindMarkdown, } from "./reporters/markdown.js";
|
|
16
19
|
export { toJsonEnvelope } from "./reporters/json.js";
|
package/dist/index.js
CHANGED
|
@@ -14,13 +14,23 @@ export { diffServerWeigh } from "./weigh/diff.js";
|
|
|
14
14
|
export { countTokens } from "./weigh/tokens.js";
|
|
15
15
|
// Skills
|
|
16
16
|
export { findSkills, parseSkillFile } from "./skills/parse.js";
|
|
17
|
+
// Find (findability under deferred tool loading)
|
|
18
|
+
//
|
|
19
|
+
// Exported because docs/INTEGRATIONS.md promises "every capability is also a
|
|
20
|
+
// typed function", and the blanket re-export of core/types.js was already
|
|
21
|
+
// publishing FindResult and ToolFindEntry: a public type with nothing public
|
|
22
|
+
// that produces one. runFindRules is here for the same reason its output is,
|
|
23
|
+
// even though the rule TABLE stays private like the other two.
|
|
24
|
+
export { analyzeFind, indexedTerms, taskQuery, DEFAULT_TOP_K, DEFAULT_QUERY_TERMS, DEFER_RECOMMENDED_TOKENS, DEFER_RECOMMENDED_TOOLS } from "./find/find.js";
|
|
25
|
+
export { runFindRules } from "./core/engine.js";
|
|
26
|
+
export { RULES_VERSION } from "./rules/version.js";
|
|
17
27
|
// Check (audit)
|
|
18
28
|
export { checkMcpTarget, checkMcpRepoOnly, checkSkillSet } from "./check/check.js";
|
|
19
29
|
// Test (skill A/B outcome harness)
|
|
20
30
|
export { parseScenario, runScenario, armSystems, } from "./testing/harness.js";
|
|
21
|
-
export { anthropicRunner } from "./testing/anthropicRunner.js";
|
|
31
|
+
export { anthropicRunner, acceptsSamplingParams, anthropicRequestBody } from "./testing/anthropicRunner.js";
|
|
22
32
|
export { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
|
|
23
33
|
// Reporters (report -> string; stable value-in/string-out contract)
|
|
24
|
-
export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, } from "./reporters/pretty.js";
|
|
25
|
-
export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, } from "./reporters/markdown.js";
|
|
34
|
+
export { renderCheckPretty, renderSkillSetPretty, renderServerWeighPretty, renderSkillWeighPretty, renderDiffPretty, renderScenarioPlan, renderTestReportPretty, renderFindPretty, renderFindFindingsPretty, } from "./reporters/pretty.js";
|
|
35
|
+
export { renderCheckMarkdown, renderSkillSetMarkdown, renderWeighMarkdown, renderDiffMarkdown, renderFindMarkdown, } from "./reporters/markdown.js";
|
|
26
36
|
export { toJsonEnvelope } from "./reporters/json.js";
|
package/dist/reporters/json.d.ts
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type EnvelopeKind = "check" | "weigh" | "test" | "find";
|
|
2
|
+
export interface EnvelopeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Include `generatedAt`. Default true for a report a human reads.
|
|
5
|
+
*
|
|
6
|
+
* Off for anything meant to be committed, diffed or compared. A wall-clock
|
|
7
|
+
* stamp inside the payload makes every regeneration a diff even when not one
|
|
8
|
+
* measured number moved, which is how a re-run that changed nothing still
|
|
9
|
+
* produces a commit that has to be reviewed as though it might have. This
|
|
10
|
+
* project has shipped several releases whose only data change was described
|
|
11
|
+
* in its own notes as "stamp-only".
|
|
12
|
+
*
|
|
13
|
+
* `--out` baselines always drop it: a baseline exists to be compared against,
|
|
14
|
+
* and a field that differs on every write is the one field a comparison must
|
|
15
|
+
* ignore.
|
|
16
|
+
*/
|
|
17
|
+
timestamp?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function toJsonEnvelope(kind: EnvelopeKind, data: unknown, opts?: EnvelopeOptions): string;
|