@retinue/agentkit 0.1.0 → 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/README.md +59 -277
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +99 -1
- package/dist/agents/agent.js +215 -16
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +48 -0
- package/dist/agents/engine.js +370 -13
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +26 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +110 -2
- package/dist/core/events.js +33 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/validation.js +19 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +17 -0
- package/dist/entries/knowledge.js +16 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +7 -1
- package/dist/graphql/resolvers.js +20 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +25 -0
- package/dist/knowledge/index.js +37 -1
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +98 -5
- package/dist/knowledge/retrieval.js +158 -28
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +111 -1
- package/dist/models/streaming.js +184 -3
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +55 -0
- package/dist/security/findings.js +18 -9
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/telemetry/spans.js +17 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +31 -1
- package/dist/toolkit/index.js +26 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +316 -0
- package/dist/tools/credentials.js +369 -0
- package/dist/tools/define.d.ts +45 -0
- package/dist/tools/define.js +26 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +35 -2
- package/dist/tools/index.js +11 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +37 -2
- package/dist/tools/library/index.js +47 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +250 -4
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +33 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval without vectors — REQ-050 (#209), task #219, AC-4.
|
|
3
|
+
*
|
|
4
|
+
* A **spike**, and the deliverable is a decision with numbers rather than a subsystem. See
|
|
5
|
+
* `docs/26-retrieval-quality.md` for what it scored.
|
|
6
|
+
*
|
|
7
|
+
* ## The idea being tested
|
|
8
|
+
*
|
|
9
|
+
* Embedding-based retrieval matches a query against fragments of text and hopes the fragments it surfaces are the
|
|
10
|
+
* ones that answer it. A person looking something up in a manual does something else entirely: they read the
|
|
11
|
+
* table of contents, decide which chapter is relevant, and then read it. That needs no index, no embedding cost
|
|
12
|
+
* and no re-indexing when a document changes — and its citations name a *document* somebody chose rather than a
|
|
13
|
+
* fragment a cosine distance surfaced.
|
|
14
|
+
*
|
|
15
|
+
* The cost is a model call per query, and latency measured in seconds rather than milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* ## Two things this prototype found immediately
|
|
18
|
+
*
|
|
19
|
+
* **`KnowledgeStore` cannot enumerate its sources.** There is `listBySource`, `get`, `deleteSource` and
|
|
20
|
+
* `staleSources`, and no way to ask "what documents are in here". That is correct for a vector-based design —
|
|
21
|
+
* nothing needed it — and it is exactly what a navigating retriever needs first. So the outline arrives through
|
|
22
|
+
* a port the *host* supplies (`OutlineCatalogue`), which is honest but means this mode is not a drop-in for a
|
|
23
|
+
* deployment that already has hybrid retrieval working.
|
|
24
|
+
*
|
|
25
|
+
* **It does fit behind `RetrievalMode`**, which the issue asked to test. `createRetriever` gains one optional
|
|
26
|
+
* dependency and a fourth mode; every caller — `search_knowledge` included — is unchanged, and a deployment that
|
|
27
|
+
* has not wired a navigator gets a named refusal rather than a silent fall back to semantic search. If it had
|
|
28
|
+
* *not* fit, that would have been the finding; it fits.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_MAX_SOURCES = 3;
|
|
31
|
+
export const DEFAULT_MAX_CHUNKS_PER_SOURCE = 40;
|
|
32
|
+
const reference = (chunk) => ({
|
|
33
|
+
sourceType: chunk.sourceType,
|
|
34
|
+
sourceId: chunk.sourceId,
|
|
35
|
+
chunkIndex: chunk.chunkIndex,
|
|
36
|
+
chunkId: chunk.id,
|
|
37
|
+
...(chunk.locator === undefined ? {} : { locator: chunk.locator }),
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Query terms, for ordering chunks *within* the documents the chooser picked.
|
|
41
|
+
*
|
|
42
|
+
* Six lines of tokenisation rather than a second ranker: there is no fusion here and no relevance floor, because
|
|
43
|
+
* the relevance decision was already made — by a model, over titles and headings. What remains is "which part of
|
|
44
|
+
* this chapter", and term overlap answers that well enough to measure. If the eval had shown this mode worth
|
|
45
|
+
* shipping, this is the first thing to replace.
|
|
46
|
+
*/
|
|
47
|
+
const terms = (text) => [
|
|
48
|
+
...new Set(text.toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length > 2)),
|
|
49
|
+
];
|
|
50
|
+
export const createNavigator = (deps) => {
|
|
51
|
+
const maxSources = deps.maxSources ?? DEFAULT_MAX_SOURCES;
|
|
52
|
+
const maxChunks = deps.maxChunksPerSource ?? DEFAULT_MAX_CHUNKS_PER_SOURCE;
|
|
53
|
+
return {
|
|
54
|
+
id: `navigate:${deps.chooser.id}`,
|
|
55
|
+
async navigate(context, input) {
|
|
56
|
+
if (input.authSubjects.length === 0)
|
|
57
|
+
return { found: false, reason: "no-access", message: "There is no material you have access to.", mode: "navigate" };
|
|
58
|
+
const catalogue = await deps.catalogue.list({ tenantId: context.tenantId, authSubjects: input.authSubjects });
|
|
59
|
+
if (catalogue.length === 0)
|
|
60
|
+
return { found: false, reason: "nothing-indexed", message: "There is no indexed material to search yet.", mode: "navigate" };
|
|
61
|
+
const chosen = await deps.chooser.choose({ query: input.query, catalogue, limit: maxSources });
|
|
62
|
+
/**
|
|
63
|
+
* An empty choice is a real answer, and a distinct one.
|
|
64
|
+
*
|
|
65
|
+
* A model that has read the table of contents and concluded nothing there is relevant has told you
|
|
66
|
+
* something a cosine distance cannot: `no-match` rather than the least-bad chapter. This is the mode's most
|
|
67
|
+
* attractive property and the reason it is worth measuring at all.
|
|
68
|
+
*/
|
|
69
|
+
if (chosen.length === 0)
|
|
70
|
+
return { found: false, reason: "no-match", message: "Nothing in the available material covers that.", mode: "navigate" };
|
|
71
|
+
const known = new Map(catalogue.map((outline) => [outline.sourceId, outline]));
|
|
72
|
+
const wanted = input.query.toLowerCase();
|
|
73
|
+
const queryTerms = terms(input.query);
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const sourceId of chosen.slice(0, maxSources)) {
|
|
76
|
+
const outline = known.get(sourceId);
|
|
77
|
+
// A chooser naming a document that is not in the catalogue it was given is a chooser that hallucinated
|
|
78
|
+
// one. Skipped rather than fetched: fetching would be a model choosing which document to read.
|
|
79
|
+
if (outline === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
const page = await deps.store.listBySource({
|
|
82
|
+
tenantId: context.tenantId,
|
|
83
|
+
sourceType: outline.sourceType,
|
|
84
|
+
sourceId,
|
|
85
|
+
limit: maxChunks,
|
|
86
|
+
});
|
|
87
|
+
for (const chunk of page.items) {
|
|
88
|
+
const content = chunk.content.toLowerCase();
|
|
89
|
+
const overlap = queryTerms.filter((term) => content.includes(term)).length;
|
|
90
|
+
hits.push({
|
|
91
|
+
chunk,
|
|
92
|
+
// Not comparable with a fused score, and deliberately so: this number orders chunks inside a chosen
|
|
93
|
+
// document and means nothing outside one.
|
|
94
|
+
score: queryTerms.length === 0 ? 0 : overlap / queryTerms.length + (content.includes(wanted) ? 1 : 0),
|
|
95
|
+
signals: ["navigate"],
|
|
96
|
+
reference: reference(chunk),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (hits.length === 0)
|
|
101
|
+
return { found: false, reason: "no-match", message: "The chosen documents had no readable content.", mode: "navigate" };
|
|
102
|
+
const ordered = [...hits].sort((a, b) => b.score !== a.score ? b.score - a.score : a.reference.chunkId.localeCompare(b.reference.chunkId));
|
|
103
|
+
return { found: true, hits: ordered.slice(0, input.limit), mode: "navigate" };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
//# sourceMappingURL=navigate.js.map
|
|
@@ -14,9 +14,19 @@
|
|
|
14
14
|
*
|
|
15
15
|
* score(d) = Σ over signals of 1 / (K + rank(d))
|
|
16
16
|
*
|
|
17
|
-
* A document ranked first by one signal and absent from the other still beats one ranked fifth by both, which
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* A document ranked first by one signal and absent from the other still beats one ranked fifth by both, which is
|
|
18
|
+
* the behaviour hybrid exists for — the exact-term hit surfaces even though the semantic signal never saw it.
|
|
19
|
+
*
|
|
20
|
+
* **And on one real corpus it is worse than semantic alone.** This comment used to say hybrid "measurably beats"
|
|
21
|
+
* both parts; #219 measured it over 56 documents of technical prose and hybrid lost 11.1 points of success@5 to
|
|
22
|
+
* semantic-only — two cases out of eighteen. The mechanism is the same paragraph read the other way: RRF weights both signals equally by
|
|
23
|
+
* construction, so fusing a weak list with a strong one demotes the strong list's top hits wherever the weak one
|
|
24
|
+
* disagrees. The lexical signal is weak on natural-language questions over prose.
|
|
25
|
+
*
|
|
26
|
+
* Hybrid remains the default, deliberately — that dataset has 18 queries, one author, and **no identifier
|
|
27
|
+
* queries**, which is precisely the case hybrid exists for. But the claim in this comment was untested for months
|
|
28
|
+
* and turned out to be false where it was finally tested, so it is stated with its evidence now. See
|
|
29
|
+
* `docs/26-retrieval-quality.md`.
|
|
20
30
|
*
|
|
21
31
|
* **`K = 60`** is the value from Cormack, Clarke and Buettcher's original TREC work and the one every
|
|
22
32
|
* implementation since has used. It is large relative to the ranks that matter, which flattens the difference
|
|
@@ -30,6 +40,9 @@
|
|
|
30
40
|
import type { TenantId } from "../core/ids.js";
|
|
31
41
|
import type { KeywordIndex, KnowledgeChunk, KnowledgeSourceType, VectorIndex } from "../persistence/index.js";
|
|
32
42
|
import type { EmbeddingProvider } from "./index.js";
|
|
43
|
+
import type { Navigator } from "./navigate.js";
|
|
44
|
+
import type { GraphLocalSearch } from "./graph-retrieval.js";
|
|
45
|
+
import type { GraphGlobalSearch } from "./graph-global.js";
|
|
33
46
|
/** The rank-fusion constant. See the note above on why 60 and why rank rather than score. */
|
|
34
47
|
export declare const RRF_K = 60;
|
|
35
48
|
/** How many candidates each signal contributes before fusion. */
|
|
@@ -42,7 +55,25 @@ export declare const DEFAULT_CANDIDATES = 20;
|
|
|
42
55
|
* second-best answer and tight enough to reject a corpus that simply has nothing to say.
|
|
43
56
|
*/
|
|
44
57
|
export declare const DEFAULT_RELEVANCE_FLOOR = 0.4;
|
|
45
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `navigate` is a **spike** — REQ-050 (#209), task #219, AC-4.
|
|
60
|
+
*
|
|
61
|
+
* The issue asked whether retrieval without vectors can be expressed as a fourth mode behind this interface, and
|
|
62
|
+
* "if it cannot, that is itself the finding". It can: `createRetriever` gains one optional dependency, and every
|
|
63
|
+
* caller — `search_knowledge` included — is unchanged. A deployment that has not wired a navigator gets a named
|
|
64
|
+
* refusal rather than a silent fall back to semantic search, which is the failure that would have made the mode
|
|
65
|
+
* dangerous rather than merely unused.
|
|
66
|
+
*
|
|
67
|
+
* See `navigate.ts` for what it is and `docs/26-retrieval-quality.md` for what it scored.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* `graph-local` joins the union in REQ-064 (#270), task #273.
|
|
71
|
+
*
|
|
72
|
+
* A closed union read in several places, so a new member is threaded rather than cast past — the same
|
|
73
|
+
* discipline #219 applied when it added `not-configured`. Like `navigate`, it is *delegated whole*: it shares
|
|
74
|
+
* no step with the fusion path, because it answers a different kind of question.
|
|
75
|
+
*/
|
|
76
|
+
export type RetrievalMode = "semantic" | "keyword" | "hybrid" | "navigate" | "graph-local" | "graph-global";
|
|
46
77
|
/** What a citation needs, derived from a hit so there is one shape rather than each caller's own (AC-6). */
|
|
47
78
|
export type SourceReference = {
|
|
48
79
|
readonly sourceType: KnowledgeSourceType;
|
|
@@ -67,7 +98,7 @@ export type RetrievalHit = {
|
|
|
67
98
|
* different answer from "you have no indexed documents", and telling a user the first when the second is true
|
|
68
99
|
* sends them looking for content they never uploaded.
|
|
69
100
|
*/
|
|
70
|
-
export declare const NO_RESULT_REASONS: readonly ["nothing-indexed", "no-match", "below-threshold", "no-access"];
|
|
101
|
+
export declare const NO_RESULT_REASONS: readonly ["nothing-indexed", "no-match", "below-threshold", "no-access", "not-configured"];
|
|
71
102
|
export type NoResultReason = (typeof NO_RESULT_REASONS)[number];
|
|
72
103
|
export type RetrievalOutcome = {
|
|
73
104
|
readonly found: true;
|
|
@@ -85,6 +116,14 @@ export type RetrievalOutcome = {
|
|
|
85
116
|
* A port, and **switchable**, because a reranker's value is a claim that has to be provable. A cross-encoder is
|
|
86
117
|
* materially more expensive than the retrieval it reorders, so "we rerank" without a measured contribution is
|
|
87
118
|
* a cost nobody justified. Absent means fusion order stands, which is the honest default.
|
|
119
|
+
*
|
|
120
|
+
* **`createExactTermReranker`'s measured contribution is negative** — #219, over 56 documents of technical prose:
|
|
121
|
+
* −5.6 points of success@5, −5.6 of recall, −0.028 MRR, and no latency saving. It promotes chunks containing query
|
|
122
|
+
* terms verbatim, which on prose queries promotes chunks that happen to repeat a common word. Leave it off.
|
|
123
|
+
*
|
|
124
|
+
* That is a result about *that* reranker, not about reranking: a cross-encoder is a different mechanism and might
|
|
125
|
+
* well earn its cost. This port is how it would be measured, and `docs/26-retrieval-quality.md` is the baseline to
|
|
126
|
+
* measure it against.
|
|
88
127
|
*/
|
|
89
128
|
export interface Reranker {
|
|
90
129
|
readonly id: string;
|
|
@@ -111,6 +150,30 @@ export type RetrieverDeps = {
|
|
|
111
150
|
* "no match"), the best of them normalises to 1.0, and every query finds something.
|
|
112
151
|
*/
|
|
113
152
|
readonly semanticFloor?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Serves `mode: "navigate"` — task #219, AC-4.
|
|
155
|
+
*
|
|
156
|
+
* Optional, and its absence is a *named refusal* for that mode rather than a fall back to semantic search: a
|
|
157
|
+
* caller that asked for navigation and silently got embeddings would attribute the results to the wrong
|
|
158
|
+
* mechanism, which is the only way this spike could have done harm.
|
|
159
|
+
*/
|
|
160
|
+
readonly navigator?: Navigator;
|
|
161
|
+
/**
|
|
162
|
+
* Serves `mode: "graph-local"` — REQ-064 (#270), task #273.
|
|
163
|
+
*
|
|
164
|
+
* Optional for the same reason `navigator` is, and its absence is the same named refusal. GraphRAG is opt-in
|
|
165
|
+
* and expensive to index, so a deployment that never enabled it supplies nothing here and asking for the
|
|
166
|
+
* mode says so rather than quietly returning embeddings.
|
|
167
|
+
*/
|
|
168
|
+
readonly graphLocal?: GraphLocalSearch;
|
|
169
|
+
/**
|
|
170
|
+
* Serves `mode: "graph-global"` — REQ-064 (#270), task #274.
|
|
171
|
+
*
|
|
172
|
+
* Separate from `graphLocal` rather than one graph dependency, because they are independently useful: a
|
|
173
|
+
* deployment can run entity-centric retrieval without paying for community summaries at all, and #273 landed
|
|
174
|
+
* before #272 for exactly that reason.
|
|
175
|
+
*/
|
|
176
|
+
readonly graphGlobal?: GraphGlobalSearch;
|
|
114
177
|
};
|
|
115
178
|
export type RetrieveInput = {
|
|
116
179
|
readonly query: string;
|
|
@@ -126,6 +189,36 @@ export type RetrieveInput = {
|
|
|
126
189
|
/** Defaults to `hybrid`. The other two exist so the hybrid claim can be measured against them. */
|
|
127
190
|
readonly mode?: RetrievalMode;
|
|
128
191
|
};
|
|
192
|
+
/**
|
|
193
|
+
* Reciprocal rank fusion, extracted so there is exactly one of it — REQ-045 (#204), task #210, AC-2.
|
|
194
|
+
*
|
|
195
|
+
* `find_tools` fuses two signals over tool descriptors and this fuses two signals over knowledge chunks. Those
|
|
196
|
+
* are the same algorithm with a different corpus, and writing it twice is the shape this repository keeps
|
|
197
|
+
* finding defects in: the second copy drifts, usually in the tie-break or the normalisation, and the drift
|
|
198
|
+
* shows up as one ranker being subtly worse with nothing pointing at why.
|
|
199
|
+
*
|
|
200
|
+
* Generic over the item and its key. The **key** is what merges an item found by both signals; without it a
|
|
201
|
+
* chunk in both lists would fuse with itself and score twice.
|
|
202
|
+
*
|
|
203
|
+
* Scores come back normalised against the best fused score, because a raw RRF sum means nothing on its own —
|
|
204
|
+
* `2/61` is not "poor", it is "found first by both signals". Normalising is what lets one relevance floor apply
|
|
205
|
+
* to any corpus, tools included.
|
|
206
|
+
*/
|
|
207
|
+
export type FusedEntry<T, S extends string> = {
|
|
208
|
+
readonly item: T;
|
|
209
|
+
/** 0–1, relative to the best entry in this fusion. Comparable within one query, never across two. */
|
|
210
|
+
readonly score: number;
|
|
211
|
+
readonly signals: readonly S[];
|
|
212
|
+
};
|
|
213
|
+
export declare const fuseByRank: <T, S extends string>(input: {
|
|
214
|
+
readonly lists: readonly {
|
|
215
|
+
readonly signal: S;
|
|
216
|
+
readonly items: readonly T[];
|
|
217
|
+
}[];
|
|
218
|
+
readonly keyOf: (item: T) => string;
|
|
219
|
+
/** The rank-fusion constant. Defaults to `RRF_K`; a caller changing it should say why. */
|
|
220
|
+
readonly k?: number;
|
|
221
|
+
}) => readonly FusedEntry<T, S>[];
|
|
129
222
|
export declare const createRetriever: (deps: RetrieverDeps) => {
|
|
130
223
|
rerankerId: string | null;
|
|
131
224
|
retrieve(context: {
|
|
@@ -14,9 +14,19 @@
|
|
|
14
14
|
*
|
|
15
15
|
* score(d) = Σ over signals of 1 / (K + rank(d))
|
|
16
16
|
*
|
|
17
|
-
* A document ranked first by one signal and absent from the other still beats one ranked fifth by both, which
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* A document ranked first by one signal and absent from the other still beats one ranked fifth by both, which is
|
|
18
|
+
* the behaviour hybrid exists for — the exact-term hit surfaces even though the semantic signal never saw it.
|
|
19
|
+
*
|
|
20
|
+
* **And on one real corpus it is worse than semantic alone.** This comment used to say hybrid "measurably beats"
|
|
21
|
+
* both parts; #219 measured it over 56 documents of technical prose and hybrid lost 11.1 points of success@5 to
|
|
22
|
+
* semantic-only — two cases out of eighteen. The mechanism is the same paragraph read the other way: RRF weights both signals equally by
|
|
23
|
+
* construction, so fusing a weak list with a strong one demotes the strong list's top hits wherever the weak one
|
|
24
|
+
* disagrees. The lexical signal is weak on natural-language questions over prose.
|
|
25
|
+
*
|
|
26
|
+
* Hybrid remains the default, deliberately — that dataset has 18 queries, one author, and **no identifier
|
|
27
|
+
* queries**, which is precisely the case hybrid exists for. But the claim in this comment was untested for months
|
|
28
|
+
* and turned out to be false where it was finally tested, so it is stated with its evidence now. See
|
|
29
|
+
* `docs/26-retrieval-quality.md`.
|
|
20
30
|
*
|
|
21
31
|
* **`K = 60`** is the value from Cormack, Clarke and Buettcher's original TREC work and the one every
|
|
22
32
|
* implementation since has used. It is large relative to the ranks that matter, which flattens the difference
|
|
@@ -47,7 +57,52 @@ export const DEFAULT_RELEVANCE_FLOOR = 0.4;
|
|
|
47
57
|
* different answer from "you have no indexed documents", and telling a user the first when the second is true
|
|
48
58
|
* sends them looking for content they never uploaded.
|
|
49
59
|
*/
|
|
50
|
-
export const NO_RESULT_REASONS = [
|
|
60
|
+
export const NO_RESULT_REASONS = [
|
|
61
|
+
"nothing-indexed",
|
|
62
|
+
"no-match",
|
|
63
|
+
"below-threshold",
|
|
64
|
+
"no-access",
|
|
65
|
+
/**
|
|
66
|
+
* The mode asked for is not wired.
|
|
67
|
+
*
|
|
68
|
+
* Its own reason rather than `no-match`, because the two want opposite responses: one says rephrase, this says
|
|
69
|
+
* a deployment has not configured what you asked for. Falling back to another mode silently would be worse
|
|
70
|
+
* than either, since the caller would attribute the results to the mode it named.
|
|
71
|
+
*
|
|
72
|
+
* **Live, not a placeholder** — audited for #256 AC-8. Three code paths return it (`navigate` with no
|
|
73
|
+
* navigator, `graph-local` and `graph-global` with no GraphRAG), and three test files assert it. The optional
|
|
74
|
+
* modes are exactly why it has to exist: GraphRAG ships **off by default** on the measured evidence in
|
|
75
|
+
* `docs/29`, so "you asked for a mode this deployment did not switch on" is an ordinary answer rather than an
|
|
76
|
+
* edge case. The comment previously cited task #219 as if it were pending; it closed.
|
|
77
|
+
*/
|
|
78
|
+
"not-configured",
|
|
79
|
+
];
|
|
80
|
+
export const fuseByRank = (input) => {
|
|
81
|
+
const k = input.k ?? RRF_K;
|
|
82
|
+
const fused = new Map();
|
|
83
|
+
for (const list of input.lists) {
|
|
84
|
+
list.items.forEach((item, rank) => {
|
|
85
|
+
const key = input.keyOf(item);
|
|
86
|
+
const increment = 1 / (k + rank + 1);
|
|
87
|
+
const existing = fused.get(key);
|
|
88
|
+
if (existing === undefined)
|
|
89
|
+
fused.set(key, { item, key, score: increment, signals: new Set([list.signal]) });
|
|
90
|
+
else {
|
|
91
|
+
existing.score += increment;
|
|
92
|
+
existing.signals.add(list.signal);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Key order breaks ties, so two runs over the same corpus produce the same ranking. A `Map` iteration order
|
|
97
|
+
// tie-break would depend on which signal happened to return first.
|
|
98
|
+
const ordered = [...fused.values()].sort((a, b) => (b.score !== a.score ? b.score - a.score : a.key.localeCompare(b.key)));
|
|
99
|
+
const best = ordered[0]?.score ?? 0;
|
|
100
|
+
return ordered.map((entry) => ({
|
|
101
|
+
item: entry.item,
|
|
102
|
+
score: best === 0 ? 0 : entry.score / best,
|
|
103
|
+
signals: [...entry.signals],
|
|
104
|
+
}));
|
|
105
|
+
};
|
|
51
106
|
const referenceFor = (chunk) => ({
|
|
52
107
|
sourceType: chunk.sourceType,
|
|
53
108
|
sourceId: chunk.sourceId,
|
|
@@ -60,6 +115,7 @@ const NO_RESULT_MESSAGES = {
|
|
|
60
115
|
"no-match": "Nothing in the available material matches that.",
|
|
61
116
|
"below-threshold": "Nothing in the available material is a close enough match to rely on.",
|
|
62
117
|
"no-access": "There is no material you have access to that matches that.",
|
|
118
|
+
"not-configured": "That retrieval mode is not configured for this deployment.",
|
|
63
119
|
};
|
|
64
120
|
export const createRetriever = (deps) => {
|
|
65
121
|
const candidateCount = deps.candidates ?? DEFAULT_CANDIDATES;
|
|
@@ -69,6 +125,91 @@ export const createRetriever = (deps) => {
|
|
|
69
125
|
rerankerId: deps.reranker?.id ?? null,
|
|
70
126
|
async retrieve(context, input) {
|
|
71
127
|
const mode = input.mode ?? "hybrid";
|
|
128
|
+
/**
|
|
129
|
+
* The graph mode, delegated whole — task #273.
|
|
130
|
+
*
|
|
131
|
+
* Before the `authSubjects` check below only because it does its own: an empty subject list means the
|
|
132
|
+
* same thing here, and the graph search returns an empty result for it rather than traversing first and
|
|
133
|
+
* filtering after. Placed with `navigate` because both are delegated modes rather than fusion inputs.
|
|
134
|
+
*/
|
|
135
|
+
if (mode === "graph-local") {
|
|
136
|
+
if (deps.graphLocal === undefined)
|
|
137
|
+
return { found: false, reason: "not-configured", message: NO_RESULT_MESSAGES["not-configured"], mode };
|
|
138
|
+
if (input.authSubjects.length === 0)
|
|
139
|
+
return { found: false, reason: "no-access", message: NO_RESULT_MESSAGES["no-access"], mode };
|
|
140
|
+
const result = await deps.graphLocal.search(context, {
|
|
141
|
+
query: input.query,
|
|
142
|
+
authSubjects: input.authSubjects,
|
|
143
|
+
limit: input.limit,
|
|
144
|
+
...(input.sourceTypes === undefined ? {} : { sourceTypes: input.sourceTypes }),
|
|
145
|
+
});
|
|
146
|
+
if (result.hits.length === 0)
|
|
147
|
+
/**
|
|
148
|
+
* An honest empty result, and the reason distinguishes the two ways it happens.
|
|
149
|
+
*
|
|
150
|
+
* Nothing in the question named an entity the graph knows is `no-match` — rephrasing might work.
|
|
151
|
+
* Entities matched but reached no readable chunk is also `no-match` rather than `no-access`, because
|
|
152
|
+
* the principal may genuinely have access to a corpus that simply says nothing more.
|
|
153
|
+
*/
|
|
154
|
+
return { found: false, reason: "no-match", message: NO_RESULT_MESSAGES["no-match"], mode };
|
|
155
|
+
return {
|
|
156
|
+
found: true,
|
|
157
|
+
mode,
|
|
158
|
+
hits: result.hits.map((hit) => ({
|
|
159
|
+
chunk: hit.chunk,
|
|
160
|
+
score: hit.score,
|
|
161
|
+
signals: ["graph-local"],
|
|
162
|
+
reference: referenceFor(hit.chunk),
|
|
163
|
+
})),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The corpus-level mode — task #274.
|
|
168
|
+
*
|
|
169
|
+
* Its cost ceiling **throws** rather than returning an outcome, and that is deliberate: exceeding it is
|
|
170
|
+
* not "found nothing", it is "this question is too expensive to answer this way", and the two want
|
|
171
|
+
* different responses. `RetrievalOutcome`'s `found: false` reasons are all about the corpus; this is
|
|
172
|
+
* about the request.
|
|
173
|
+
*/
|
|
174
|
+
if (mode === "graph-global") {
|
|
175
|
+
if (deps.graphGlobal === undefined)
|
|
176
|
+
return { found: false, reason: "not-configured", message: NO_RESULT_MESSAGES["not-configured"], mode };
|
|
177
|
+
if (input.authSubjects.length === 0)
|
|
178
|
+
return { found: false, reason: "no-access", message: NO_RESULT_MESSAGES["no-access"], mode };
|
|
179
|
+
const result = await deps.graphGlobal.search(context, {
|
|
180
|
+
query: input.query,
|
|
181
|
+
authSubjects: input.authSubjects,
|
|
182
|
+
limit: input.limit,
|
|
183
|
+
...(input.sourceTypes === undefined ? {} : { sourceTypes: input.sourceTypes }),
|
|
184
|
+
});
|
|
185
|
+
if (result.hits.length === 0)
|
|
186
|
+
return { found: false, reason: "no-match", message: NO_RESULT_MESSAGES["no-match"], mode };
|
|
187
|
+
return {
|
|
188
|
+
found: true,
|
|
189
|
+
mode,
|
|
190
|
+
hits: result.hits.map((hit) => ({
|
|
191
|
+
chunk: hit.chunk,
|
|
192
|
+
score: hit.score,
|
|
193
|
+
signals: ["graph-global"],
|
|
194
|
+
reference: referenceFor(hit.chunk),
|
|
195
|
+
})),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// The spike's mode, delegated whole: it shares no step with the fusion path below.
|
|
199
|
+
if (mode === "navigate") {
|
|
200
|
+
if (deps.navigator === undefined)
|
|
201
|
+
return {
|
|
202
|
+
found: false,
|
|
203
|
+
reason: "not-configured",
|
|
204
|
+
message: NO_RESULT_MESSAGES["not-configured"],
|
|
205
|
+
mode,
|
|
206
|
+
};
|
|
207
|
+
return deps.navigator.navigate(context, {
|
|
208
|
+
query: input.query,
|
|
209
|
+
authSubjects: input.authSubjects,
|
|
210
|
+
limit: input.limit,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
72
213
|
// Checked before either index is asked. An empty subject list is not a query with no results — it is a
|
|
73
214
|
// caller with no access, and the two want different sentences.
|
|
74
215
|
if (input.authSubjects.length === 0)
|
|
@@ -93,30 +234,19 @@ export const createRetriever = (deps) => {
|
|
|
93
234
|
const lexical = mode === "semantic" ? [] : await deps.keyword.search({ ...scope, query: input.query });
|
|
94
235
|
if (semantic.length === 0 && lexical.length === 0)
|
|
95
236
|
return { found: false, reason: "no-match", message: NO_RESULT_MESSAGES["no-match"], mode };
|
|
96
|
-
// RRF. Rank, not score: see the note at the top on why adding two
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
};
|
|
110
|
-
contribute(semantic, "semantic");
|
|
111
|
-
contribute(lexical, "keyword");
|
|
112
|
-
const ordered = [...fused.values()].sort((a, b) => b.score !== a.score ? b.score - a.score : a.chunk.id.localeCompare(b.chunk.id));
|
|
113
|
-
const best = ordered[0]?.score ?? 0;
|
|
114
|
-
const candidates = ordered.map((entry) => ({
|
|
115
|
-
chunk: entry.chunk,
|
|
116
|
-
// Normalised against the best fused score, so the floor means the same thing whatever the corpus.
|
|
117
|
-
score: best === 0 ? 0 : entry.score / best,
|
|
118
|
-
signals: [...entry.signals],
|
|
119
|
-
reference: referenceFor(entry.chunk),
|
|
237
|
+
// RRF, through the shared implementation. Rank, not score: see the note at the top on why adding two
|
|
238
|
+
// incomparable scales fails silently, and `fuseByRank` on why there is only one of these.
|
|
239
|
+
const candidates = fuseByRank({
|
|
240
|
+
lists: [
|
|
241
|
+
{ signal: "semantic", items: semantic },
|
|
242
|
+
{ signal: "keyword", items: lexical },
|
|
243
|
+
],
|
|
244
|
+
keyOf: (hit) => hit.chunk.id,
|
|
245
|
+
}).map((entry) => ({
|
|
246
|
+
chunk: entry.item.chunk,
|
|
247
|
+
score: entry.score,
|
|
248
|
+
signals: entry.signals,
|
|
249
|
+
reference: referenceFor(entry.item.chunk),
|
|
120
250
|
}));
|
|
121
251
|
const relevant = candidates.filter((hit) => hit.score >= floor);
|
|
122
252
|
if (relevant.length === 0)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An inbound MCP server — REQ-059 (#249), task #250.
|
|
3
|
+
*
|
|
4
|
+
* `backend/src/mcp/` is the **outbound** direction: a tenant registers their MCP server and this platform
|
|
5
|
+
* consumes it. Its own header says so, and points at an inbound server that lives in the *old Chorus
|
|
6
|
+
* repository* — not in this package. So `@retinue/agentkit` could consume an MCP server and could not be one,
|
|
7
|
+
* and a deployment's tools were unreachable from Claude Code, Claude Desktop, Cursor and every other MCP client.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here re-implements a capability. The registry already does authorization, the tenant's toolset, the
|
|
10
|
+
* approval gate, validation, idempotency and audit attribution; this is a protocol surface in front of it, and
|
|
11
|
+
* every call goes through `registry.execute` exactly as an agent's would.
|
|
12
|
+
*
|
|
13
|
+
* ## The trust direction inverts, and that is the thing to get right
|
|
14
|
+
*
|
|
15
|
+
* Outbound, `mcp/index.ts` establishes the rule: a remote server's `readOnlyHint`/`destructiveHint` are
|
|
16
|
+
* *advisory and untrusted*, and "a remote server cannot talk its way down to" a weaker effect. Inbound, **this
|
|
17
|
+
* package is the remote server**. Two consequences, and both are enforced rather than intended:
|
|
18
|
+
*
|
|
19
|
+
* 1. What it advertises must be what it enforces. The annotations are derived from `ToolEffect` by one
|
|
20
|
+
* function, so they cannot drift from the effect the registry acts on.
|
|
21
|
+
* 2. Nothing the client sends is trusted — not an effect, not a hint, not a claim about a prior approval. The
|
|
22
|
+
* client supplies a tool name and arguments; everything else comes from the context the *host* built.
|
|
23
|
+
*
|
|
24
|
+
* ## Authentication is structural
|
|
25
|
+
*
|
|
26
|
+
* `createMcpToolServer` takes a resolved `ExecutionContext`. There is no `authenticate` callback to forget and
|
|
27
|
+
* no default: a host that has not authenticated has nothing to pass, so it cannot construct a server. That is
|
|
28
|
+
* the same decision `server/main.ts` makes — "a permissive default would serve an open API to anyone who forgot
|
|
29
|
+
* to set it" — expressed in the type instead of in a runtime check, because this surface gets exposed to the
|
|
30
|
+
* internet and a runtime check can be skipped.
|
|
31
|
+
*
|
|
32
|
+
* One server per authenticated session: a stdio server is launched per client and carries that client's
|
|
33
|
+
* identity for its lifetime; an HTTP mount builds one per authenticated request.
|
|
34
|
+
*/
|
|
35
|
+
import type { ExecutionContext } from "../core/context.js";
|
|
36
|
+
import type { ToolDescriptor, ToolEffect, ToolRegistry } from "../tools/index.js";
|
|
37
|
+
/**
|
|
38
|
+
* The MCP SDK, imported for its types only where possible.
|
|
39
|
+
*
|
|
40
|
+
* An **optional peer dependency**, following `vitest` on `./testing`: a consumer who never mounts an MCP server
|
|
41
|
+
* never installs it, and the only path to this module is its own subpath.
|
|
42
|
+
*/
|
|
43
|
+
export type McpServerLike = {
|
|
44
|
+
/**
|
|
45
|
+
* `any`, and it is the correct choice rather than a shortcut.
|
|
46
|
+
*
|
|
47
|
+
* The SDK's real signature is generic —
|
|
48
|
+
* `<T extends AnyObjectSchema>(schema: T, handler: (request: SchemaOutput<T>, extra: RequestHandlerExtra) => …)`
|
|
49
|
+
* — and this package cannot restate those generics without importing the SDK, which would stop it being an
|
|
50
|
+
* optional peer. A structural type with `unknown`/`never` parameters looks stricter and is **not assignable
|
|
51
|
+
* from** the SDK's `Server`, so the first version of this failed to typecheck at the only call site anybody
|
|
52
|
+
* will write. `check:consumer` caught it by compiling the documented sample against the packed tarball:
|
|
53
|
+
*
|
|
54
|
+
* Argument of type 'Server<…>' is not assignable to parameter of type 'McpServerLike'
|
|
55
|
+
* Type 'any' is not assignable to type 'never'
|
|
56
|
+
*
|
|
57
|
+
* A type nothing real satisfies is worse than a permissive one.
|
|
58
|
+
*/
|
|
59
|
+
setRequestHandler(schema: any,
|
|
60
|
+
/**
|
|
61
|
+
* The result is an object, not `unknown` — the second half of the same lesson.
|
|
62
|
+
*
|
|
63
|
+
* The SDK's handler must return a `ServerResult`, and `Promise<unknown>` is *wider* than that, so a `Server`
|
|
64
|
+
* was still not assignable even after the parameters were relaxed. A permissive object shape satisfies it
|
|
65
|
+
* in both directions.
|
|
66
|
+
*/
|
|
67
|
+
handler: (request: any, extra?: any) => Promise<Record<string, unknown>>): void;
|
|
68
|
+
connect(transport: any): Promise<void>;
|
|
69
|
+
};
|
|
70
|
+
export type McpToolAnnotations = {
|
|
71
|
+
readonly readOnlyHint: boolean;
|
|
72
|
+
readonly destructiveHint: boolean;
|
|
73
|
+
readonly idempotentHint: boolean;
|
|
74
|
+
/** True when the tool reaches something outside this deployment. */
|
|
75
|
+
readonly openWorldHint: boolean;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* `ToolEffect` → MCP annotations, in **one** place — AC-7.
|
|
79
|
+
*
|
|
80
|
+
* One function, so the advertised hint and the enforced effect cannot drift: a test asserts this mapping over
|
|
81
|
+
* every effect, and the exposed set is checked against it. Two tables would be two chances to disagree, and the
|
|
82
|
+
* disagreement would be a client told a tool is read-only calling something that writes.
|
|
83
|
+
*
|
|
84
|
+
* `destructive` is the only `destructiveHint`, and `read` the only `readOnlyHint` — deliberately narrow.
|
|
85
|
+
* Over-claiming read-only is the dangerous direction: a client may skip a confirmation on it.
|
|
86
|
+
*/
|
|
87
|
+
export declare const annotationsFor: (effect: ToolEffect) => McpToolAnnotations;
|
|
88
|
+
/**
|
|
89
|
+
* A tool's input schema, in the shape MCP requires — found by a real client.
|
|
90
|
+
*
|
|
91
|
+
* MCP's `tools/list` demands `inputSchema.type === "object"`, and the SDK's *client* validates it: a tool whose
|
|
92
|
+
* descriptor holds a Zod schema made the whole listing fail with `expected "object"` at one index. So the
|
|
93
|
+
* failure was not "one tool looks odd", it was **no tools at all**, from one non-conforming entry.
|
|
94
|
+
*
|
|
95
|
+
* Three cases, in order of fidelity:
|
|
96
|
+
*
|
|
97
|
+
* 1. Already a JSON-Schema object — pass it through unchanged. It is what execution validates against, so
|
|
98
|
+
* advertising anything else would advertise a contract nothing enforces.
|
|
99
|
+
* 2. A Zod schema — convert it. `defineTool` documents `inputSchema` as "Zod schema or JSON-schema object", so
|
|
100
|
+
* both are legitimate and a client deserves the real one either way.
|
|
101
|
+
* 3. Anything else — `{ type: "object" }`, permissive. Deliberately the *weaker* advertisement rather than a
|
|
102
|
+
* refusal to list: the registry still validates the real schema on execution, so the failure direction is a
|
|
103
|
+
* rejected call rather than an accepted bad one.
|
|
104
|
+
*/
|
|
105
|
+
export declare const toMcpInputSchema: (schema: unknown) => Record<string, unknown>;
|
|
106
|
+
/** What a client is shown for one tool. */
|
|
107
|
+
export declare const describeForMcp: (descriptor: ToolDescriptor) => {
|
|
108
|
+
name: string;
|
|
109
|
+
title: string;
|
|
110
|
+
description: string;
|
|
111
|
+
inputSchema: Record<string, unknown>;
|
|
112
|
+
annotations: McpToolAnnotations;
|
|
113
|
+
};
|
|
114
|
+
export type McpToolServerDeps = {
|
|
115
|
+
readonly registry: ToolRegistry;
|
|
116
|
+
/**
|
|
117
|
+
* The authenticated caller. **Required, and there is no default.**
|
|
118
|
+
*
|
|
119
|
+
* A host that has not authenticated cannot construct a server, which is the guarantee expressed in the type
|
|
120
|
+
* rather than in a check somebody can skip.
|
|
121
|
+
*/
|
|
122
|
+
readonly context: ExecutionContext;
|
|
123
|
+
readonly serverInfo?: {
|
|
124
|
+
readonly name: string;
|
|
125
|
+
readonly version: string;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* The result of a `tools/call`, in MCP's shape.
|
|
130
|
+
*
|
|
131
|
+
* A refusal is `isError: true` with the reason as text — **not** a protocol error. That distinction matters: a
|
|
132
|
+
* protocol error tells the client the server is broken, and a tool refusal is a normal outcome the model should
|
|
133
|
+
* see and respond to. It is the same decision `streamModelTurn` makes for `tool-error`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const toMcpResult: (outcome: {
|
|
136
|
+
readonly ok: true;
|
|
137
|
+
readonly data: unknown;
|
|
138
|
+
} | {
|
|
139
|
+
readonly ok: false;
|
|
140
|
+
readonly error: {
|
|
141
|
+
readonly code: string;
|
|
142
|
+
readonly message: string;
|
|
143
|
+
};
|
|
144
|
+
}) => {
|
|
145
|
+
content: {
|
|
146
|
+
type: "text";
|
|
147
|
+
text: string;
|
|
148
|
+
}[];
|
|
149
|
+
isError?: undefined;
|
|
150
|
+
} | {
|
|
151
|
+
content: {
|
|
152
|
+
type: "text";
|
|
153
|
+
text: string;
|
|
154
|
+
}[];
|
|
155
|
+
isError: true;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Registers the two handlers on an SDK `Server`.
|
|
159
|
+
*
|
|
160
|
+
* Takes the server rather than constructing it, so the SDK stays an optional peer of this package: the host
|
|
161
|
+
* imports `@modelcontextprotocol/sdk`, builds a `Server`, and hands it here. That also leaves the host in
|
|
162
|
+
* charge of the transport, which is where the authentication story differs between stdio and HTTP.
|
|
163
|
+
*/
|
|
164
|
+
export declare const registerRetinueTools: (server: McpServerLike, schemas: {
|
|
165
|
+
readonly listTools: unknown;
|
|
166
|
+
readonly callTool: unknown;
|
|
167
|
+
}, deps: McpToolServerDeps) => void;
|
|
168
|
+
//# sourceMappingURL=index.d.ts.map
|