efaimo 0.1.2 → 0.3.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 +160 -1
- package/README.md +75 -6
- package/SKILL.md +14 -7
- package/dist/check/diff.d.ts +124 -0
- package/dist/check/diff.js +165 -0
- package/dist/cli.js +237 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +144 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +122 -0
- package/dist/find/find.js +405 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +14 -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 +25 -1
- package/dist/reporters/pretty.js +228 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/skill/index.js +32 -1
- 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 +3 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, exact BM25 index.
|
|
3
|
+
*
|
|
4
|
+
* `efaimo find` needs to answer "would a search over this catalog surface this
|
|
5
|
+
* tool", and the built-in tool search is server-side: its parameters are not
|
|
6
|
+
* published, so nothing here can be a reproduction. It is the textbook
|
|
7
|
+
* Robertson/Sparck-Jones BM25 with its conventional parameters, run offline on
|
|
8
|
+
* the four fields Anthropic documents as searchable, and both the formula and
|
|
9
|
+
* the parameters are printed in the output. A reader can disagree with the
|
|
10
|
+
* model and still check the arithmetic.
|
|
11
|
+
*
|
|
12
|
+
* Two properties this file exists to guarantee:
|
|
13
|
+
*
|
|
14
|
+
* - **Determinism.** Ties break on the document id by code point, never by
|
|
15
|
+
* `localeCompare`, whose collation depends on the host's ICU data. A tool
|
|
16
|
+
* that reports other people's nondeterminism (E112) must not have any of its
|
|
17
|
+
* own, and "it ranked differently on CI" is exactly the bug that would be
|
|
18
|
+
* impossible to reproduce.
|
|
19
|
+
* - **No silent empties.** `score()` on an empty query returns an empty array
|
|
20
|
+
* rather than the whole catalog at score 0. Callers treat that as a finding,
|
|
21
|
+
* not as a pass.
|
|
22
|
+
*/
|
|
23
|
+
/** The conventional defaults. Printed in every report so the method travels with the number. */
|
|
24
|
+
export const DEFAULT_BM25 = { k1: 1.2, b: 0.75 };
|
|
25
|
+
export class Bm25Index {
|
|
26
|
+
params;
|
|
27
|
+
size;
|
|
28
|
+
ids;
|
|
29
|
+
tf;
|
|
30
|
+
len;
|
|
31
|
+
df = new Map();
|
|
32
|
+
avgdl;
|
|
33
|
+
constructor(docs, params = DEFAULT_BM25) {
|
|
34
|
+
this.params = params;
|
|
35
|
+
this.size = docs.length;
|
|
36
|
+
this.ids = docs.map((d) => d.id);
|
|
37
|
+
this.tf = docs.map((d) => {
|
|
38
|
+
const m = new Map();
|
|
39
|
+
for (const t of d.terms)
|
|
40
|
+
m.set(t, (m.get(t) ?? 0) + 1);
|
|
41
|
+
return m;
|
|
42
|
+
});
|
|
43
|
+
this.len = docs.map((d) => d.terms.length);
|
|
44
|
+
for (const m of this.tf) {
|
|
45
|
+
for (const t of m.keys())
|
|
46
|
+
this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const total = this.len.reduce((a, b) => a + b, 0);
|
|
49
|
+
// A catalog of zero-length documents would divide by zero in the length
|
|
50
|
+
// normalization. It cannot happen with real tools (a name always tokenizes
|
|
51
|
+
// to something) but the guard costs nothing and a NaN score would rank
|
|
52
|
+
// unpredictably rather than fail.
|
|
53
|
+
this.avgdl = docs.length ? Math.max(1, total / docs.length) : 1;
|
|
54
|
+
}
|
|
55
|
+
/** BM25 IDF. Always positive, so a term present in every document still contributes. */
|
|
56
|
+
idf(term) {
|
|
57
|
+
const n = this.df.get(term) ?? 0;
|
|
58
|
+
return Math.log(1 + (this.size - n + 0.5) / (n + 0.5));
|
|
59
|
+
}
|
|
60
|
+
/** Document frequency: how many tools contain this term at all. */
|
|
61
|
+
documentFrequency(term) {
|
|
62
|
+
return this.df.get(term) ?? 0;
|
|
63
|
+
}
|
|
64
|
+
termFrequency(index, term) {
|
|
65
|
+
return this.tf[index]?.get(term) ?? 0;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Score every document against a query, highest first.
|
|
69
|
+
*
|
|
70
|
+
* Duplicate query terms are collapsed: a query is a set of keywords here,
|
|
71
|
+
* not a passage, and counting a repeated term twice would silently weight it.
|
|
72
|
+
*/
|
|
73
|
+
score(query) {
|
|
74
|
+
const terms = [...new Set(query)];
|
|
75
|
+
if (!terms.length)
|
|
76
|
+
return [];
|
|
77
|
+
const { k1, b } = this.params;
|
|
78
|
+
const hits = [];
|
|
79
|
+
for (let i = 0; i < this.size; i++) {
|
|
80
|
+
let s = 0;
|
|
81
|
+
const norm = k1 * (1 - b + (b * this.len[i]) / this.avgdl);
|
|
82
|
+
for (const t of terms) {
|
|
83
|
+
const f = this.tf[i].get(t);
|
|
84
|
+
if (!f)
|
|
85
|
+
continue;
|
|
86
|
+
s += this.idf(t) * ((f * (k1 + 1)) / (f + norm));
|
|
87
|
+
}
|
|
88
|
+
hits.push({ id: this.ids[i], score: s, index: i });
|
|
89
|
+
}
|
|
90
|
+
// Ties break on the id by code point, then on position, so two documents
|
|
91
|
+
// with the same name and the same score still have a fixed order.
|
|
92
|
+
hits.sort((x, y) => (y.score - x.score) || (x.id < y.id ? -1 : x.id > y.id ? 1 : 0) || (x.index - y.index));
|
|
93
|
+
return hits;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Are two scores the same as far as a ranker is concerned?
|
|
98
|
+
*
|
|
99
|
+
* Relative, because BM25 scores are unbounded: an absolute epsilon that is
|
|
100
|
+
* right at score 2 is meaningless at score 40. Two tools this close are
|
|
101
|
+
* separated only by the tie-break, which means the search picks between them
|
|
102
|
+
* on the alphabet rather than on relevance.
|
|
103
|
+
*/
|
|
104
|
+
export function scoresTie(a, bScore, rel = 1e-9) {
|
|
105
|
+
const scale = Math.max(Math.abs(a), Math.abs(bScore), 1);
|
|
106
|
+
return Math.abs(a - bScore) <= rel * scale;
|
|
107
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { FindResult, ToolDef } from "../core/types.js";
|
|
2
|
+
import { Bm25Index } from "./bm25.js";
|
|
3
|
+
/**
|
|
4
|
+
* Findability: can a search over this catalog pick this tool out at all?
|
|
5
|
+
*
|
|
6
|
+
* Why this exists. Since 2026-01 a host can mark tools `defer_loading: true`,
|
|
7
|
+
* which keeps them out of the system-prompt prefix entirely; the model then
|
|
8
|
+
* finds them by searching the catalog, and Anthropic recommends turning that
|
|
9
|
+
* on once tool definitions pass ~10k tokens. Under deferral a tool no search
|
|
10
|
+
* surfaces costs zero tokens and provides zero capability, and no existing
|
|
11
|
+
* check looks at that: `weigh` measures what a tool costs, `check` measures
|
|
12
|
+
* what a model experiences once the tool is already in front of it.
|
|
13
|
+
*
|
|
14
|
+
* Two measurements, and they are not the same kind of thing.
|
|
15
|
+
*
|
|
16
|
+
* 1. EXCLUSIVE VOCABULARY, the headline. Which terms does a tool have that no
|
|
17
|
+
* other tool in the catalog has? A tool with none cannot be matched by any
|
|
18
|
+
* query that does not also match a competitor, because every word it
|
|
19
|
+
* contains is somewhere else too. That is a fact about the catalog, checked
|
|
20
|
+
* by inspection, with no model of how anyone searches, no parameters, and
|
|
21
|
+
* nothing to disagree with except the tokenizer.
|
|
22
|
+
*
|
|
23
|
+
* It also discriminates, which the alternative did not. Measured on
|
|
24
|
+
* `@playwright/mcp` 0.0.78: 22 of 24 tools own a word, and `browser_close`
|
|
25
|
+
* and `browser_navigate` own none.
|
|
26
|
+
*
|
|
27
|
+
* 2. THE SEARCH PROBE, secondary. Simulated BM25 over the four fields tool
|
|
28
|
+
* search documents as searchable, each tool queried with the top terms of
|
|
29
|
+
* its own description. This is a model, and a generous one: the query comes
|
|
30
|
+
* from the tool's own words. It reads 100% on both the official reference
|
|
31
|
+
* server and playwright, so it is a floor test that catches catastrophe -
|
|
32
|
+
* an empty description, a literal duplicate - and not a ranking. The report
|
|
33
|
+
* says so on every run rather than letting a saturated number look like a
|
|
34
|
+
* good score.
|
|
35
|
+
*
|
|
36
|
+
* Neither is a grade. See ADR-030 for why this ships as proportions.
|
|
37
|
+
*/
|
|
38
|
+
/** How many of a tool's own terms make up its self-query. */
|
|
39
|
+
export declare const DEFAULT_QUERY_TERMS = 4;
|
|
40
|
+
/**
|
|
41
|
+
* How many results a search returns. Anthropic documents a default of 5 (the
|
|
42
|
+
* model may raise it), so 5 is what a tool has to land inside to be seen
|
|
43
|
+
* without the model deciding to look harder.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_TOP_K = 5;
|
|
46
|
+
/**
|
|
47
|
+
* When Anthropic's own guidance says to turn tool search on.
|
|
48
|
+
*
|
|
49
|
+
* The doc lists five conditions joined by "any of the following apply", and
|
|
50
|
+
* two of them can be checked from a tool list: "You have 10 or more tools
|
|
51
|
+
* available" and "Your tool definitions consume more than 10k tokens". The
|
|
52
|
+
* other three are about the operator's situation, not the catalog.
|
|
53
|
+
*
|
|
54
|
+
* Both are checked. An earlier version implemented only the token clause,
|
|
55
|
+
* which told a reader that a 24-tool, 3.5k-token server was "probably loaded
|
|
56
|
+
* up front today" when Anthropic's first condition already applied to it, and
|
|
57
|
+
* left E145 structurally unable to fire on the most common shape there is: a
|
|
58
|
+
* catalog with plenty of tools and modest descriptions.
|
|
59
|
+
*/
|
|
60
|
+
export declare const DEFER_RECOMMENDED_TOKENS = 10000;
|
|
61
|
+
export declare const DEFER_RECOMMENDED_TOOLS = 10;
|
|
62
|
+
/**
|
|
63
|
+
* The four fields tool search matches on: name, description, argument names,
|
|
64
|
+
* argument descriptions. Kept in one function so the index and the docs can
|
|
65
|
+
* never disagree about what is searched.
|
|
66
|
+
*
|
|
67
|
+
* `title` is deliberately NOT among them, for two reasons that point the same
|
|
68
|
+
* way. Anthropic's list of searchable fields is these four and does not
|
|
69
|
+
* include it. And MCP `title` is a human-readable restatement of the name:
|
|
70
|
+
* every one of the reference server's thirteen tools sets one, and every one
|
|
71
|
+
* is the name title-cased ("Echo Tool", "Get Annotated Message Tool"). An
|
|
72
|
+
* earlier version indexed and PROBED with it, which quietly put the tool's own
|
|
73
|
+
* name back into its own query through the back door and undid the whole
|
|
74
|
+
* reason the name is excluded. Two tools with identical descriptions and
|
|
75
|
+
* name-derived titles scored a clean rank 1 each instead of tying.
|
|
76
|
+
*
|
|
77
|
+
* Indexing a field the real search does not read would also make `distinct`
|
|
78
|
+
* optimistic: a tool could "own" a word that exists only in its title, where
|
|
79
|
+
* no query can reach it.
|
|
80
|
+
*/
|
|
81
|
+
export declare function indexedTerms(tool: ToolDef): string[];
|
|
82
|
+
/**
|
|
83
|
+
* The terms a person would plausibly type to reach this tool.
|
|
84
|
+
*
|
|
85
|
+
* Drawn from the description, and deliberately NOT from the name, the title,
|
|
86
|
+
* or the argument names.
|
|
87
|
+
*
|
|
88
|
+
* Not the name, because the person searching does not know it. That is the
|
|
89
|
+
* whole situation being modelled: the tool is deferred, the user describes a
|
|
90
|
+
* task, and the model turns that description into a query. An earlier version
|
|
91
|
+
* of this drew query terms from the name too, and the result was a metric that
|
|
92
|
+
* could barely fail - a unique name token has maximal idf, so it entered every
|
|
93
|
+
* query and pulled its own tool to the top. Two tools with the same
|
|
94
|
+
* description and different names both scored a clean rank 1, which is exactly
|
|
95
|
+
* backwards: identical descriptions are the case where a searcher cannot tell
|
|
96
|
+
* them apart. The name stays in the INDEX, so a name whose words echo the
|
|
97
|
+
* description still helps the tool rank.
|
|
98
|
+
*
|
|
99
|
+
* Not the title either, and that one is the same bug wearing a different hat:
|
|
100
|
+
* MCP `title` is usually the name title-cased, so reading it here put the name
|
|
101
|
+
* back into its own query after all the trouble taken to keep it out. See
|
|
102
|
+
* `indexedTerms`, which drops it from the index for the same reason.
|
|
103
|
+
*
|
|
104
|
+
* Not argument names, because a user describes a task ("post a message to
|
|
105
|
+
* slack"), not a parameter list. They are indexed, so a search can still reach
|
|
106
|
+
* them; they are just never what the tool is probed with.
|
|
107
|
+
*
|
|
108
|
+
* Ranked by tf*idf inside this catalog, so the terms chosen are the ones that
|
|
109
|
+
* separate this tool from its siblings, with stopwords excluded because in a
|
|
110
|
+
* small catalog a function word repeated in one description can outrank the
|
|
111
|
+
* noun that identifies the tool.
|
|
112
|
+
*/
|
|
113
|
+
export declare function taskQuery(tool: ToolDef, index: Bm25Index, limit?: number): string[];
|
|
114
|
+
export interface FindOptions {
|
|
115
|
+
/** Labels of the servers merged into this catalog, when more than one. */
|
|
116
|
+
sources?: string[];
|
|
117
|
+
topK?: number;
|
|
118
|
+
queryTerms?: number;
|
|
119
|
+
/** Claude-style tool-definition tokens, used only to say whether deferral is likely. */
|
|
120
|
+
definitionTokens?: number;
|
|
121
|
+
}
|
|
122
|
+
export declare function analyzeFind(label: string, tools: readonly ToolDef[], opts?: FindOptions): FindResult;
|
|
@@ -0,0 +1,405 @@
|
|
|
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
|
+
...(tool.origin ? { origin: tool.origin } : {}),
|
|
316
|
+
ownTerms: own.slice(0, 4),
|
|
317
|
+
ownTermCount: own.length,
|
|
318
|
+
ownOutsideNameCount: ownOutsideName.length,
|
|
319
|
+
sharedWith,
|
|
320
|
+
query,
|
|
321
|
+
rank,
|
|
322
|
+
score,
|
|
323
|
+
outrankedBy: outrankedBy.slice(0, 3),
|
|
324
|
+
tiedWith: tied.slice(0, 3),
|
|
325
|
+
// Kept separately from the display cap. The rule that counts pairs was
|
|
326
|
+
// counting the capped list, so a catalog where every tool ties with
|
|
327
|
+
// every other reported 30 pairs instead of the real 66: a count of what
|
|
328
|
+
// survived a display cap, presented as a count of the problem.
|
|
329
|
+
tiedWithCount: tied.length,
|
|
330
|
+
reachable: rank !== undefined && rank <= topK,
|
|
331
|
+
genericName: nameIsGeneric(tool.name),
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
const distinctCount = perTool.filter((t) => t.ownTermCount > 0).length;
|
|
335
|
+
const reachable = perTool.filter((t) => t.reachable).length;
|
|
336
|
+
const windowCoversCatalog = tools.length <= topK;
|
|
337
|
+
const distinctVacuous = tools.length < 2;
|
|
338
|
+
const deferBecause = [];
|
|
339
|
+
if (tools.length >= DEFER_RECOMMENDED_TOOLS) {
|
|
340
|
+
deferBecause.push(`${tools.length} tools (Anthropic recommends tool search at ${DEFER_RECOMMENDED_TOOLS} or more)`);
|
|
341
|
+
}
|
|
342
|
+
if ((opts.definitionTokens ?? 0) > DEFER_RECOMMENDED_TOKENS) {
|
|
343
|
+
deferBecause.push(`~${(opts.definitionTokens ?? 0).toLocaleString("en-US")} tokens of definitions (Anthropic recommends tool search over ${DEFER_RECOMMENDED_TOKENS.toLocaleString("en-US")})`);
|
|
344
|
+
}
|
|
345
|
+
const deferRecommended = deferBecause.length > 0;
|
|
346
|
+
const notes = [
|
|
347
|
+
`method: terms come from the four fields tool search documents as searchable (tool name, description, argument names, argument descriptions). ` +
|
|
348
|
+
`"distinct" is a property of the catalog, not a model. The probe is a simulated BM25 (k1=${DEFAULT_BM25.k1}, b=${DEFAULT_BM25.b}), ` +
|
|
349
|
+
`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.`,
|
|
350
|
+
`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.`,
|
|
351
|
+
];
|
|
352
|
+
// Vacuity is stated, not hidden, and there are TWO kinds of it.
|
|
353
|
+
//
|
|
354
|
+
// The probe goes vacuous whenever the catalog fits inside the result window.
|
|
355
|
+
// `distinct` goes vacuous only at one tool, where every term trivially has
|
|
356
|
+
// document frequency 1 and the figure is 100% for any catalog whatsoever.
|
|
357
|
+
// The first version of this said "exclusive vocabulary can fail at any
|
|
358
|
+
// catalog size, including two tools", which is true at two and false at one,
|
|
359
|
+
// and then reassured the reader that everything except the probe was "still
|
|
360
|
+
// meaningful" while the headline number was the one that had stopped being
|
|
361
|
+
// evidence. One-tool servers are real: sequential-thinking is one.
|
|
362
|
+
if (windowCoversCatalog) {
|
|
363
|
+
notes.push(`VACUOUS PROBE: ${tools.length} tools and a result window of ${topK}, so no tool can fall outside it. ` +
|
|
364
|
+
`The probe figure is 100% by construction. Ranks, exclusive vocabulary and the findings below are still evidence.`);
|
|
365
|
+
}
|
|
366
|
+
if (distinctVacuous) {
|
|
367
|
+
notes.push(`VACUOUS DISTINCT: a one-tool catalog has nothing to be distinct from, so every term it has is trivially exclusive ` +
|
|
368
|
+
`and the figure is 100% for any tool whatsoever. It is not evidence about this server, and --min-distinct cannot be evaluated here.`);
|
|
369
|
+
}
|
|
370
|
+
// Duplicate names are a real defect and nothing else here reports them. The
|
|
371
|
+
// ranking no longer confuses two tools that share a name, but a client that
|
|
372
|
+
// routes by name still will.
|
|
373
|
+
const dupNames = [...new Set(tools.map((t) => t.name).filter((nm, i, all) => all.indexOf(nm) !== i))];
|
|
374
|
+
if (dupNames.length) {
|
|
375
|
+
notes.push(`${dupNames.length} tool name${dupNames.length === 1 ? " is" : "s are"} used by more than one tool (${dupNames.slice(0, 3).join(", ")}). ` +
|
|
376
|
+
`Ranks here are per tool, but a client that addresses tools by name cannot tell them apart at all.`);
|
|
377
|
+
}
|
|
378
|
+
notes.push(deferRecommended
|
|
379
|
+
? `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.`
|
|
380
|
+
: `${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.`);
|
|
381
|
+
return {
|
|
382
|
+
kind: "find",
|
|
383
|
+
label,
|
|
384
|
+
...(opts.sources && opts.sources.length > 1 ? { sources: opts.sources } : {}),
|
|
385
|
+
toolCount: tools.length,
|
|
386
|
+
method: { tokenizer: "efaimo-v1", bm25: { ...DEFAULT_BM25 }, queryTerms, topK },
|
|
387
|
+
perTool,
|
|
388
|
+
distinct: {
|
|
389
|
+
count: distinctCount,
|
|
390
|
+
total: tools.length,
|
|
391
|
+
pct: Math.round((distinctCount / tools.length) * 1000) / 10,
|
|
392
|
+
},
|
|
393
|
+
probe: {
|
|
394
|
+
returned: reachable,
|
|
395
|
+
total: tools.length,
|
|
396
|
+
pct: Math.round((reachable / tools.length) * 1000) / 10,
|
|
397
|
+
},
|
|
398
|
+
windowCoversCatalog,
|
|
399
|
+
distinctVacuous,
|
|
400
|
+
definitionTokens: opts.definitionTokens,
|
|
401
|
+
deferRecommended,
|
|
402
|
+
deferBecause,
|
|
403
|
+
notes,
|
|
404
|
+
};
|
|
405
|
+
}
|