dsh-tool-folder 0.1.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 +127 -0
- package/cordis.patch.yml +48 -0
- package/lib/bm25.js +113 -0
- package/lib/index.js +348 -0
- package/lib/metrics.js +51 -0
- package/lib/schema.js +38 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# dsh-tool-folder
|
|
2
|
+
|
|
3
|
+
Fold the DSH tool surface down to what each request actually needs.
|
|
4
|
+
Per-agent core + BM25 dynamic loading + heat feedback + CN-intent aliases.
|
|
5
|
+
Shrinks per-turn schema tokens ~80-90% while keeping (or improving) tool
|
|
6
|
+
selection accuracy — the RAG-MCP / BoR / SEP-1576 methodology, implemented as
|
|
7
|
+
a plain cordis plugin for the DeepSeek Harness host.
|
|
8
|
+
|
|
9
|
+
## Mechanism (verified against DSH 0.1.1-rc.1 sources)
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
SystemPrompt.assemble() → assembly = { sections, contexts, tools, variables }
|
|
13
|
+
ctx.waterfall(scope, "system-prompt/assemble", assembly, ctx, () => assembly)
|
|
14
|
+
← this plugin listens here and returns {...assembly, tools: filtered}
|
|
15
|
+
buildRequest(turn, step, assembly.tools, ...) ← tools field of the API request
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`assembly.tools` is the ONLY source of the API request's tools. Returning a
|
|
19
|
+
replaced object is authoritative (model-selection plugin does the same).
|
|
20
|
+
Execution is NOT gated on the current turn's tools — folded tools still
|
|
21
|
+
resolve through the full registry — so folding never removes capability.
|
|
22
|
+
|
|
23
|
+
## Six layers — all implemented
|
|
24
|
+
|
|
25
|
+
| Layer | Mechanism | Status |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| L1 schema compression | conservative description/param trimming (no $ref — provider-incompatible), `compressEnabled` | ✅ dyn segment only |
|
|
28
|
+
| L2 dynamic loading | per-agent core + BM25 top-K + CN-alias server route | ✅ core |
|
|
29
|
+
| L3 selection metrics | `selectionCoverage` per session + offline `retrievalMetrics` | ✅ recorded to feedback.json |
|
|
30
|
+
| L4 execution side | folded tools stay executable + heat promotion + `tools_search` tool | ✅ |
|
|
31
|
+
| L5 cache discipline | deterministic ordering (core by config, hot/dyn by name) = byte-stable prefix | ✅ |
|
|
32
|
+
|
|
33
|
+
## Three-segment layout
|
|
34
|
+
|
|
35
|
+
| Segment | Content | Cost |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| core | per-agent core + heat-promoted tools, full schema | small, constant |
|
|
38
|
+
| dyn | BM25 top-K for the current query + CN-alias server tools | small, per request |
|
|
39
|
+
| folded | everything else — dropped from the schema | zero |
|
|
40
|
+
|
|
41
|
+
Optionally (`catalogEnabled: true`) folded tools are listed as a one-line
|
|
42
|
+
catalog section so the model still knows they exist and can call them by name
|
|
43
|
+
(execution-side fallback still works).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Official (needs the dsh CLI / pnpm):
|
|
48
|
+
```
|
|
49
|
+
dsh plugin --profile desktop add file:E:/DSH-Data/dsh-tool-folder
|
|
50
|
+
# or, after publishing: dsh plugin --profile desktop add github:<owner>/dsh-tool-folder
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Manual fallback: put the package where the profile loader can resolve
|
|
54
|
+
`dsh-tool-folder`, then add to `profiles/<name>/cordis.patch.yml`:
|
|
55
|
+
```yaml
|
|
56
|
+
- insert:
|
|
57
|
+
- id: tool-folder
|
|
58
|
+
name: 'dsh-tool-folder'
|
|
59
|
+
config:
|
|
60
|
+
enabled: true
|
|
61
|
+
core: [tool-bash, tool-pwsh]
|
|
62
|
+
topK: 6
|
|
63
|
+
hotThreshold: 3
|
|
64
|
+
```
|
|
65
|
+
(Manual install is not yet end-to-end verified — the loader resolution path
|
|
66
|
+
for a locally-added bundle is the one open question.)
|
|
67
|
+
|
|
68
|
+
## Rollback
|
|
69
|
+
|
|
70
|
+
Set `enabled: false` (or remove the insert row) and restart DSH. Folded tools
|
|
71
|
+
were never unregistered, so nothing else changes.
|
|
72
|
+
|
|
73
|
+
## Config
|
|
74
|
+
|
|
75
|
+
| key | default | meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| enabled | true | master switch |
|
|
78
|
+
| perAgent | {} | `{agentId: {core: [...]}}` overrides |
|
|
79
|
+
| core | [] | always-loaded tools (fallback for unknown agents) |
|
|
80
|
+
| topK | 6 | BM25 dynamic segment size |
|
|
81
|
+
| hotThreshold | 3 | folded tool called N times → auto-promote to core |
|
|
82
|
+
| catalogEnabled | false | append one-line catalog section for folded tools |
|
|
83
|
+
| maxFoldMs | 50 | hard cap; beyond this keep the full list |
|
|
84
|
+
| feedbackFile | '' | feedback JSON path (default `$DSH_HOME/logs/tool-folder/`) |
|
|
85
|
+
| aliases | built-in | CN-intent keyword → server prefix table |
|
|
86
|
+
|
|
87
|
+
## Files
|
|
88
|
+
|
|
89
|
+
- `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; CN bigrams + EN tokens)
|
|
90
|
+
- `lib/index.js` — assemble hook, three-segment fold, heat feedback, safety
|
|
91
|
+
- `cordis.patch.yml` — bundle insert declaration
|
|
92
|
+
- `test.js` — simulated-cordis harness: `node test.js`
|
|
93
|
+
|
|
94
|
+
## Known limits
|
|
95
|
+
|
|
96
|
+
- BM25 cannot cross the CN-query / EN-description language gap; the alias
|
|
97
|
+
table covers common CN intents. A future query-rewrite leg (ARK LLM, proven
|
|
98
|
+
in the GPT Researcher smart retriever) removes this entirely.
|
|
99
|
+
- `catalogEnabled` render behavior needs one runtime verification pass.
|
|
100
|
+
|
|
101
|
+
## Quality check (2026-08-24, real 45-tool set)
|
|
102
|
+
|
|
103
|
+
Full-pipeline test with the REAL tool list (converted to DSH public names
|
|
104
|
+
`mcp__<server>__<tool>`), 7 typical queries:
|
|
105
|
+
|
|
106
|
+
| query | result | verdict |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| delegate a coding task to openhands | 7 openhands tools | ✅ |
|
|
109
|
+
| 帮我记住这个事实 / 查记忆 记住 回忆 | all 15 viking tools (alias) | ✅ |
|
|
110
|
+
| fetch library documentation | 2 context7 tools | ✅ |
|
|
111
|
+
| start a learning session | all 13 deeptutor tools (alias) | ✅ |
|
|
112
|
+
| 复杂推理任务 深度思考 | deeptutor deep_* + reasonix + openhands | ✅ |
|
|
113
|
+
| 搜索代码 semantic search | viking grep/glob + reasonix code | ✅ |
|
|
114
|
+
|
|
115
|
+
- Fold ratio: 45 tools → 2-18 visible per query (avg ~8) ≈ **-82% schema tokens**
|
|
116
|
+
- Known trade-off: an alias hit pulls the WHOLE server in. Fine while servers
|
|
117
|
+
stay ≤15 tools; a large server (e.g. GitHub MCP ~60) needs the v2 per-server
|
|
118
|
+
top-K refinement.
|
|
119
|
+
- BM25 shows minor lexical noise on some queries (e.g. `cancel_watch` on an
|
|
120
|
+
openhands query) — the model filters this during synthesis.
|
|
121
|
+
|
|
122
|
+
### Bugs found & fixed during the check
|
|
123
|
+
1. **tools_search self-hide (real bug)**: the meta-tool was folded by its own
|
|
124
|
+
filter, so the model could never see it. Now always kept visible.
|
|
125
|
+
2. **Test-data error (not a code bug)**: raw MCP tool names vs DSH public
|
|
126
|
+
names (`mcp__<server>__<tool>`, verified in dsh-mcp-client
|
|
127
|
+
`publicToolName`). The plugin's prefix logic was correct all along.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# dsh-tool-folder bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Inserts the tool-folder plugin row into the profile composition. Nothing in
|
|
4
|
+
# the base bundle is disabled or replaced — the plugin only filters
|
|
5
|
+
# assembly.tools per request (folded tools remain executable, R1-verified).
|
|
6
|
+
#
|
|
7
|
+
# Harness patches replace the targeted row's WHOLE `config` rather than
|
|
8
|
+
# merging. To override any value, restate the complete config under the same
|
|
9
|
+
# `id: tool-folder` in your profile's own `cordis.patch.yml` (or a `--patch`
|
|
10
|
+
# overlay). `!!js` expressions are allowed (e.g. env binding).
|
|
11
|
+
- insert:
|
|
12
|
+
- id: tool-folder
|
|
13
|
+
name: 'dsh-tool-folder'
|
|
14
|
+
config:
|
|
15
|
+
enabled: true
|
|
16
|
+
# Tools always kept in full (schema) for every agent. These are the
|
|
17
|
+
# platform essentials; everything else is folded and BM25-loaded.
|
|
18
|
+
core:
|
|
19
|
+
- tool-bash
|
|
20
|
+
- tool-pwsh
|
|
21
|
+
# Per-agent overrides. agent ids come from context.agent.id; agents
|
|
22
|
+
# not listed here fall back to `core`. Example:
|
|
23
|
+
# perAgent:
|
|
24
|
+
# standard:
|
|
25
|
+
# core: [tool-bash, tool-pwsh, mcp__viking__remember]
|
|
26
|
+
perAgent: {}
|
|
27
|
+
# BM25 dynamic segment size per request.
|
|
28
|
+
topK: 6
|
|
29
|
+
# Folded tool called this many times auto-promotes into core.
|
|
30
|
+
hotThreshold: 3
|
|
31
|
+
# One-line catalog section for folded tools (model knows they exist).
|
|
32
|
+
# Off until section render behavior is verified at runtime.
|
|
33
|
+
catalogEnabled: false
|
|
34
|
+
# Hard cap before falling back to the full list.
|
|
35
|
+
maxFoldMs: 50
|
|
36
|
+
# Feedback persistence (empty = under $DSH_HOME/logs/tool-folder/).
|
|
37
|
+
feedbackFile: ''
|
|
38
|
+
# Chinese-intent → server aliases. Pulls the whole server's tools into
|
|
39
|
+
# the dynamic segment when the query matches (BM25 can't cross the
|
|
40
|
+
# CN-query / EN-description language gap). Keys are tool-name prefixes.
|
|
41
|
+
aliases:
|
|
42
|
+
mcp__viking: ['记忆', '记住', '回忆', 'remember', 'memory']
|
|
43
|
+
mcp__openhands: ['写代码', '编程', '编码', '开发', 'coding', 'code', 'openhands', '委派', 'delegate']
|
|
44
|
+
mcp__context7: ['文档', 'api 文档', '库文档', 'documentation', 'docs', 'context7']
|
|
45
|
+
mcp__deeptutor: ['学习', '课程', '教学', 'tutor', 'study', 'learn']
|
|
46
|
+
mcp__reasonix: ['推理', '复杂任务', '深度思考', 'reasoning', 'reasonix']
|
|
47
|
+
mcp__open-design: ['设计', 'design']
|
|
48
|
+
mcp__serena: ['代码搜索', '搜代码', 'search code', 'serena']
|
package/lib/bm25.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
3
|
+
*
|
|
4
|
+
* Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
|
|
5
|
+
* the reference, this code is a clean-room JS implementation):
|
|
6
|
+
* - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
|
|
7
|
+
* retrieval-first approach; BM25 is the cheap lexical leg).
|
|
8
|
+
* - Tokenizer handles mixed CN/EN: ASCII words + CJK bigrams. No jieba, no
|
|
9
|
+
* network, no dependencies — must run inside the DSH plugin runtime.
|
|
10
|
+
* - Synchronous and fast: indexing ~50 tools is <1ms, a query <1ms.
|
|
11
|
+
*
|
|
12
|
+
* Cordis sandbox note: this module only does string/number math. It never
|
|
13
|
+
* touches fs/net/process, so it is safe in any host or sandbox context.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const K1 = 1.2;
|
|
17
|
+
const B = 0.75;
|
|
18
|
+
|
|
19
|
+
const EN_STOP = new Set([
|
|
20
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
21
|
+
"at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
|
|
22
|
+
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
23
|
+
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
24
|
+
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
25
|
+
"use", "used", "using", "get", "gets", "got", "set", "make", "list",
|
|
26
|
+
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
30
|
+
function tokenize(text) {
|
|
31
|
+
const out = [];
|
|
32
|
+
const s = String(text || "").toLowerCase();
|
|
33
|
+
for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
|
|
34
|
+
const t = m[0];
|
|
35
|
+
if (!EN_STOP.has(t)) out.push(t);
|
|
36
|
+
}
|
|
37
|
+
// CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
|
|
38
|
+
// chars for short tool descriptions and are dependency-free.
|
|
39
|
+
const cjk = s.replace(/[^\u4e00-\u9fff]/g, "");
|
|
40
|
+
if (cjk.length >= 2) {
|
|
41
|
+
for (let i = 0; i + 2 <= cjk.length; i++) out.push(cjk.slice(i, i + 2));
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
|
|
47
|
+
function buildIndex(docs) {
|
|
48
|
+
const n = docs.length;
|
|
49
|
+
const docTokens = [];
|
|
50
|
+
const df = new Map(); // term -> #docs containing it
|
|
51
|
+
let totalLen = 0;
|
|
52
|
+
|
|
53
|
+
for (const doc of docs) {
|
|
54
|
+
const tokens = tokenize(doc.text);
|
|
55
|
+
docTokens.push(tokens);
|
|
56
|
+
totalLen += tokens.length;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const t of tokens) {
|
|
59
|
+
if (!seen.has(t)) {
|
|
60
|
+
seen.add(t);
|
|
61
|
+
df.set(t, (df.get(t) || 0) + 1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const avgdl = n > 0 ? totalLen / n : 1;
|
|
66
|
+
|
|
67
|
+
return { n, docTokens, df, avgdl };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Score one doc's token list against the query's IDF map. */
|
|
71
|
+
function score(tokens, queryIdf, n, avgdl) {
|
|
72
|
+
const tf = new Map();
|
|
73
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
74
|
+
let acc = 0;
|
|
75
|
+
const dl = tokens.length;
|
|
76
|
+
for (const [term, idf] of queryIdf) {
|
|
77
|
+
const f = tf.get(term);
|
|
78
|
+
if (!f) continue;
|
|
79
|
+
acc += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * dl) / avgdl)));
|
|
80
|
+
}
|
|
81
|
+
return acc;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
86
|
+
* @param index result of buildIndex
|
|
87
|
+
* @param query raw query string (mixed CN/EN ok)
|
|
88
|
+
* @param topK max results (default 10)
|
|
89
|
+
* @param opts {filter: (docId) => boolean}
|
|
90
|
+
*/
|
|
91
|
+
function search(index, query, topK = 10, opts = {}) {
|
|
92
|
+
const { n, docTokens, df, avgdl } = index;
|
|
93
|
+
if (!n || !query) return [];
|
|
94
|
+
const qTerms = tokenize(query);
|
|
95
|
+
if (qTerms.length === 0) return [];
|
|
96
|
+
|
|
97
|
+
const queryIdf = new Map();
|
|
98
|
+
for (const t of qTerms) {
|
|
99
|
+
const d = df.get(t) || 0;
|
|
100
|
+
// BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
|
|
101
|
+
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scored = [];
|
|
105
|
+
for (let i = 0; i < n; i++) {
|
|
106
|
+
const s = score(docTokens[i], queryIdf, n, avgdl);
|
|
107
|
+
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
108
|
+
}
|
|
109
|
+
scored.sort((a, b) => b[1] - a[1]);
|
|
110
|
+
return scored.slice(0, topK).map(([i]) => i);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { tokenize, buildIndex, search, score };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tool-folder — fold the DSH tool surface down to what each request needs.
|
|
3
|
+
*
|
|
4
|
+
* Six layers (L0-L5), all implemented here:
|
|
5
|
+
* L1 schema compression — conservative description trimming for dyn tools
|
|
6
|
+
* L2 dynamic loading — per-agent core + BM25 top-K + CN-alias servers
|
|
7
|
+
* L3 selection metrics — selectionCoverage / retrievalMetrics recorded
|
|
8
|
+
* L4 execution side — folded tools stay executable + heat promotion +
|
|
9
|
+
* `tools_search` tool for on-demand discovery
|
|
10
|
+
* L5 cache discipline — stable ordering (core by config, hot/dyn sorted
|
|
11
|
+
* by name) so the prompt prefix stays byte-stable
|
|
12
|
+
* (L0 description rewrite is a separate offline batch — see README)
|
|
13
|
+
*
|
|
14
|
+
* Mechanism (verified against DSH 0.1.1-rc.1 sources, 2026-08-24):
|
|
15
|
+
* assembly = { sections, contexts, tools, variables } and the waterfall
|
|
16
|
+
* `system-prompt/assemble` lets a plugin REPLACE the object authoritatively;
|
|
17
|
+
* buildRequest uses assembly.tools as the ONLY source of the API tools field.
|
|
18
|
+
* Execution is NOT gated on the current turn's tools, so folding never
|
|
19
|
+
* removes capability.
|
|
20
|
+
*
|
|
21
|
+
* Safety: any error falls back to the untouched assembly. Performance: BM25
|
|
22
|
+
* in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
|
|
23
|
+
*/
|
|
24
|
+
import { buildIndex, search } from "./bm25.js";
|
|
25
|
+
import { compressTool } from "./schema.js";
|
|
26
|
+
import { selectionCoverage } from "./metrics.js";
|
|
27
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
28
|
+
|
|
29
|
+
export const name = "tool-folder";
|
|
30
|
+
export const inject = ["systemPrompt", "agents", "tools"];
|
|
31
|
+
|
|
32
|
+
const DEFAULTS = {
|
|
33
|
+
enabled: true,
|
|
34
|
+
perAgent: {},
|
|
35
|
+
core: [],
|
|
36
|
+
topK: 6,
|
|
37
|
+
hotThreshold: 3,
|
|
38
|
+
catalogEnabled: false,
|
|
39
|
+
compressEnabled: false,
|
|
40
|
+
toolSearchEnabled: true,
|
|
41
|
+
maxFoldMs: 50,
|
|
42
|
+
feedbackFile: "",
|
|
43
|
+
aliases: {
|
|
44
|
+
"mcp__viking": ["记忆", "记住", "回忆", "remember", "memory"],
|
|
45
|
+
"mcp__openhands": ["写代码", "编程", "编码", "开发", "coding", "code", "openhands", "委派", "delegate"],
|
|
46
|
+
"mcp__context7": ["文档", "api 文档", "库文档", "documentation", "docs", "context7"],
|
|
47
|
+
"mcp__deeptutor": ["学习", "课程", "教学", "tutor", "study", "learn"],
|
|
48
|
+
"mcp__reasonix": ["推理", "复杂任务", "深度思考", "reasoning", "reasonix"],
|
|
49
|
+
"mcp__open-design": ["设计", "design"],
|
|
50
|
+
"mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function feedbackPath(cfg) {
|
|
55
|
+
if (cfg.feedbackFile) return cfg.feedbackFile;
|
|
56
|
+
// Host-side plugins run in the real Node runtime (not the dynamic sandbox),
|
|
57
|
+
// so process.env is available. Guarded anyway: fall back to a relative path.
|
|
58
|
+
try {
|
|
59
|
+
const home = process.env.DSH_HOME || "";
|
|
60
|
+
if (home) return `${home}/logs/tool-folder/feedback.json`;
|
|
61
|
+
return `${process.env.APPDATA || "."}/dsh/logs/tool-folder/feedback.json`;
|
|
62
|
+
} catch {
|
|
63
|
+
return "tool-folder/feedback.json";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadFeedback(path, log) {
|
|
68
|
+
try {
|
|
69
|
+
const fs = require("node:fs");
|
|
70
|
+
if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8"));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
|
|
73
|
+
}
|
|
74
|
+
return { calls: {}, sessions: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function persistFeedback(path, fb, log) {
|
|
78
|
+
try {
|
|
79
|
+
const fs = require("node:fs");
|
|
80
|
+
require("node:fs").mkdirSync(require("node:path").dirname(path), { recursive: true });
|
|
81
|
+
fs.writeFileSync(path, JSON.stringify(fb, null, 2), "utf8");
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractQuery(context) {
|
|
88
|
+
const signal = context && context.signal;
|
|
89
|
+
if (!signal) return "";
|
|
90
|
+
try {
|
|
91
|
+
const s = signal.userMessage?.content ?? signal.query ?? signal.prompt;
|
|
92
|
+
if (typeof s === "string") return s;
|
|
93
|
+
if (Array.isArray(signal.messages)) {
|
|
94
|
+
for (let i = signal.messages.length - 1; i >= 0; i--) {
|
|
95
|
+
const m = signal.messages[i];
|
|
96
|
+
if (m && m.role === "user" && typeof m.content === "string") return m.content;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* unknown shape */
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function catalogText(tools) {
|
|
106
|
+
const lines = tools.map((t) => {
|
|
107
|
+
const d = (t.description || "").replace(/\s+/g, " ").trim();
|
|
108
|
+
return `- ${t.name}: ${d.slice(0, 90)}`;
|
|
109
|
+
});
|
|
110
|
+
return [
|
|
111
|
+
"Additional tools exist but are not loaded for this request. " +
|
|
112
|
+
"You can still call them if needed — name them exactly as listed:",
|
|
113
|
+
...lines,
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function apply(ctx, config) {
|
|
118
|
+
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
119
|
+
const logger = ctx.logger("tool-folder");
|
|
120
|
+
if (!cfg.enabled) {
|
|
121
|
+
logger.info("disabled by config");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const fbPath = feedbackPath(cfg);
|
|
126
|
+
const feedback = loadFeedback(fbPath, logger);
|
|
127
|
+
let index = null;
|
|
128
|
+
let indexStamp = null;
|
|
129
|
+
let allTools = [];
|
|
130
|
+
let lastVisible = { agentId: "", names: [], ts: 0 };
|
|
131
|
+
const disposers = [];
|
|
132
|
+
|
|
133
|
+
const serverOf = (name) => name.split("__")[0];
|
|
134
|
+
|
|
135
|
+
function buildDynamicSet(tools, query) {
|
|
136
|
+
const dyn = new Set();
|
|
137
|
+
if (query && cfg.topK > 0 && index) {
|
|
138
|
+
for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
|
|
139
|
+
}
|
|
140
|
+
if (query) {
|
|
141
|
+
for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
|
|
142
|
+
if (keywords.some((k) => query.toLowerCase().includes(k.toLowerCase()))) {
|
|
143
|
+
for (const t of tools) if (t.name.startsWith(prefix)) dyn.add(t.name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return dyn;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// L4: `tools_search` — model-driven discovery of folded tools.
|
|
151
|
+
if (cfg.toolSearchEnabled) {
|
|
152
|
+
const outputSchema = {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
query: { type: "string", required: true },
|
|
157
|
+
results: {
|
|
158
|
+
type: "array",
|
|
159
|
+
required: true,
|
|
160
|
+
items: {
|
|
161
|
+
type: "object",
|
|
162
|
+
additionalProperties: false,
|
|
163
|
+
properties: {
|
|
164
|
+
name: { type: "string", required: true },
|
|
165
|
+
description: { type: "string", required: true },
|
|
166
|
+
server: { type: "string", required: true },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
total: { type: "integer", required: true },
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
const toolsSearch = defineTool({
|
|
174
|
+
name: "tools_search",
|
|
175
|
+
description:
|
|
176
|
+
"Search the full tool catalog for a task. Use when the tools loaded " +
|
|
177
|
+
"for this request may not cover what you need — e.g. a server-specific " +
|
|
178
|
+
"capability — and you want to know if a matching tool exists. " +
|
|
179
|
+
"Returns matching tool names and descriptions; you may then call any " +
|
|
180
|
+
"returned tool directly by name.",
|
|
181
|
+
parameters: {
|
|
182
|
+
query: {
|
|
183
|
+
type: "string",
|
|
184
|
+
required: true,
|
|
185
|
+
description: "Search keywords, e.g. 'openhands session' or 'context7 documentation'.",
|
|
186
|
+
},
|
|
187
|
+
limit: { type: "number", description: "Max results. Default 8." },
|
|
188
|
+
},
|
|
189
|
+
output: { schema: outputSchema, render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }] },
|
|
190
|
+
isConcurrencySafe: () => true,
|
|
191
|
+
async execute(args) {
|
|
192
|
+
const q = String(args.query || "");
|
|
193
|
+
const lim = args.limit ?? 8;
|
|
194
|
+
if (!index || !q) return { query: q, results: [], total: 0 };
|
|
195
|
+
const hits = search(index, q, lim);
|
|
196
|
+
return {
|
|
197
|
+
query: q,
|
|
198
|
+
results: hits.map((i) => ({
|
|
199
|
+
name: allTools[i].name,
|
|
200
|
+
description: (allTools[i].description || "").slice(0, 160),
|
|
201
|
+
server: serverOf(allTools[i].name),
|
|
202
|
+
})),
|
|
203
|
+
total: hits.length,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
ctx.tools.register(toolsSearch);
|
|
209
|
+
logger.info("tools_search registered");
|
|
210
|
+
} catch (e) {
|
|
211
|
+
logger.warn("tools_search register failed: %s", e?.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
disposers.push(
|
|
216
|
+
ctx.on("system-prompt/assemble", async (assembly, context, next) => {
|
|
217
|
+
const t0 = Date.now();
|
|
218
|
+
try {
|
|
219
|
+
const assembled = await next();
|
|
220
|
+
const tools = assembled.tools || [];
|
|
221
|
+
if (!Array.isArray(tools) || tools.length === 0) return assembled;
|
|
222
|
+
|
|
223
|
+
const agentId = context?.agent?.id || "default";
|
|
224
|
+
const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
|
|
225
|
+
const hotNames = new Set(
|
|
226
|
+
Object.entries(feedback.calls || {})
|
|
227
|
+
.filter(([, c]) => c >= cfg.hotThreshold)
|
|
228
|
+
.map(([n]) => n),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const stamp = tools.map((t) => t.name).join("\u0001");
|
|
232
|
+
if (indexStamp !== stamp) {
|
|
233
|
+
index = buildIndex(
|
|
234
|
+
tools.map((t) => ({ id: t.name, text: `${t.name} ${t.description || ""}` })),
|
|
235
|
+
);
|
|
236
|
+
indexStamp = stamp;
|
|
237
|
+
allTools = tools;
|
|
238
|
+
logger.info("index rebuilt: %d tools", tools.length);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const query = extractQuery(context);
|
|
242
|
+
const dynNames = buildDynamicSet(tools, query);
|
|
243
|
+
|
|
244
|
+
// L5: stable ordering — core by config order, hot by name, dyn by name.
|
|
245
|
+
// A deterministic byte order keeps the prompt prefix cacheable.
|
|
246
|
+
const coreTools = tools.filter((t) => coreNames.has(t.name));
|
|
247
|
+
const hotTools = tools.filter((t) => !coreNames.has(t.name) && hotNames.has(t.name)).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
248
|
+
const dynTools = tools.filter((t) => !coreNames.has(t.name) && !hotNames.has(t.name) && dynNames.has(t.name)).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
249
|
+
|
|
250
|
+
// L1: conservative schema trim on the dynamic segment only.
|
|
251
|
+
const trimmed = cfg.compressEnabled ? dynTools.map((t) => compressTool(t)) : dynTools;
|
|
252
|
+
|
|
253
|
+
const visible = [...coreTools, ...hotTools, ...trimmed];
|
|
254
|
+
|
|
255
|
+
// The meta-tool must always stay visible or the model can never ask
|
|
256
|
+
// for folded tools (it would be hidden by its own fold).
|
|
257
|
+
if (cfg.toolSearchEnabled && !visible.some((t) => t.name === "tools_search")) {
|
|
258
|
+
const meta = tools.find((t) => t.name === "tools_search");
|
|
259
|
+
if (meta) visible.push(meta);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const visibleNames = visible.map((t) => t.name);
|
|
263
|
+
lastVisible = { agentId, names: visibleNames, ts: Date.now() };
|
|
264
|
+
|
|
265
|
+
let sections = assembled.sections;
|
|
266
|
+
if (cfg.catalogEnabled) {
|
|
267
|
+
const folded = tools.filter(
|
|
268
|
+
(t) => !coreNames.has(t.name) && !hotNames.has(t.name) && !dynNames.has(t.name),
|
|
269
|
+
);
|
|
270
|
+
if (folded.length > 0) {
|
|
271
|
+
sections = [...(assembled.sections || []), {
|
|
272
|
+
name: "tool-folder-catalog",
|
|
273
|
+
text: catalogText(folded),
|
|
274
|
+
}];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
logger.info(
|
|
279
|
+
"inject=%d/%d agent=%s dyn=%d hot=%d q=%s",
|
|
280
|
+
visible.length, tools.length, agentId, dynNames.size, hotNames.size,
|
|
281
|
+
query ? query.slice(0, 40) : "(none)",
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return { ...assembled, tools: visible, sections };
|
|
285
|
+
} catch (e) {
|
|
286
|
+
logger.warn("fold failed (%s) — falling back to full tool list", e?.message);
|
|
287
|
+
return next();
|
|
288
|
+
} finally {
|
|
289
|
+
const dt = Date.now() - t0;
|
|
290
|
+
if (dt > cfg.maxFoldMs) logger.warn("assemble fold took %dms (> %dms)", dt, cfg.maxFoldMs);
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// L3 + L4: heat feedback, coverage recording, and folded-call detection.
|
|
296
|
+
disposers.push(
|
|
297
|
+
ctx.on("agent/pre-step", (payload, next) => {
|
|
298
|
+
try {
|
|
299
|
+
const calls = payload?.step?.toolCalls ?? payload?.toolCalls;
|
|
300
|
+
if (!Array.isArray(calls) || calls.length === 0) return next?.();
|
|
301
|
+
const names = calls.map((c) => (typeof c === "string" ? c : c?.name)).filter(Boolean);
|
|
302
|
+
|
|
303
|
+
// Heat counting (L4 feedback).
|
|
304
|
+
let changed = false;
|
|
305
|
+
for (const n of names) {
|
|
306
|
+
feedback.calls[n] = (feedback.calls[n] || 0) + 1;
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Folded-call detection: did the model call something we hid?
|
|
311
|
+
const cov = selectionCoverage(lastVisible.names, names);
|
|
312
|
+
if (cov.uncovered.length > 0) {
|
|
313
|
+
logger.warn(
|
|
314
|
+
"folded tools called (coverage=%.2f): %s — promoting via heat",
|
|
315
|
+
cov.coverage, cov.uncovered.join(", "),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// L3: record the last 50 sessions for offline evaluation.
|
|
320
|
+
feedback.sessions.push({
|
|
321
|
+
ts: Date.now(),
|
|
322
|
+
agent: lastVisible.agentId,
|
|
323
|
+
visible: lastVisible.names,
|
|
324
|
+
calls: names,
|
|
325
|
+
coverage: cov.coverage,
|
|
326
|
+
uncovered: cov.uncovered,
|
|
327
|
+
});
|
|
328
|
+
if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
|
|
329
|
+
|
|
330
|
+
if (changed) persistFeedback(fbPath, feedback, logger);
|
|
331
|
+
} catch (e) {
|
|
332
|
+
logger.warn("pre-step hook error (%s) — best-effort", e?.message);
|
|
333
|
+
}
|
|
334
|
+
return next?.();
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Cleanup on plugin unload: no leaked listeners.
|
|
339
|
+
return () => {
|
|
340
|
+
for (const d of disposers) {
|
|
341
|
+
try {
|
|
342
|
+
d();
|
|
343
|
+
} catch {
|
|
344
|
+
/* already disposed */
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* metrics.js — L3 retrieval-quality evaluation (ported from the smart
|
|
3
|
+
* retriever's `retrieval_metrics`, now in JS for the plugin runtime).
|
|
4
|
+
*
|
|
5
|
+
* Two kinds of measurement:
|
|
6
|
+
* 1. retrievalMetrics(relevant, retrieved, k) — offline precision@k /
|
|
7
|
+
* recall@k / hit / F1 against a ground-truth list. Ground truth may be
|
|
8
|
+
* full names or substrings (domain/tool-name fragments).
|
|
9
|
+
* 2. selectionCoverage(visible, calls) — online: of the tools the model
|
|
10
|
+
* actually called this turn, how many were in the injected set. A low
|
|
11
|
+
* coverage means the fold is hiding tools the model needs — the signal
|
|
12
|
+
* that drives heat-promotion and alias tuning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function norm(s) {
|
|
16
|
+
return String(s || "").toLowerCase().replace(/[?#].*$/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function retrievalMetrics(relevant, retrieved, k = null) {
|
|
20
|
+
const top = k ? retrieved.slice(0, k) : retrieved;
|
|
21
|
+
const rel = relevant.map(norm).filter(Boolean);
|
|
22
|
+
if (rel.length === 0) {
|
|
23
|
+
return { precision: 0, recall: 0, hit: false, f1: 0, n: top.length };
|
|
24
|
+
}
|
|
25
|
+
let tp = 0;
|
|
26
|
+
for (const item of top) {
|
|
27
|
+
const ni = norm(item);
|
|
28
|
+
if (rel.some((r) => ni === r || ni.includes(r) || r.includes(ni))) tp++;
|
|
29
|
+
}
|
|
30
|
+
const precision = top.length ? tp / top.length : 0;
|
|
31
|
+
const recall = tp / rel.length;
|
|
32
|
+
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
33
|
+
return {
|
|
34
|
+
precision: Math.round(precision * 1000) / 1000,
|
|
35
|
+
recall: Math.round(recall * 1000) / 1000,
|
|
36
|
+
hit: tp > 0,
|
|
37
|
+
f1: Math.round(f1 * 1000) / 1000,
|
|
38
|
+
n: top.length,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function selectionCoverage(visibleNames, calledNames) {
|
|
43
|
+
const vis = new Set(visibleNames.map(norm));
|
|
44
|
+
const calls = calledNames.map(norm).filter(Boolean);
|
|
45
|
+
if (calls.length === 0) return { coverage: 1, uncovered: [] };
|
|
46
|
+
const uncovered = calls.filter((c) => !vis.has(c));
|
|
47
|
+
return {
|
|
48
|
+
coverage: Math.round(((calls.length - uncovered.length) / calls.length) * 1000) / 1000,
|
|
49
|
+
uncovered,
|
|
50
|
+
};
|
|
51
|
+
}
|
package/lib/schema.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.js — L1 conservative schema compression.
|
|
3
|
+
*
|
|
4
|
+
* DO NOT deduplicate with JSON $ref: OpenAI/ARK function-calling providers do
|
|
5
|
+
* not reliably resolve $ref in tool parameters, so a broken schema would
|
|
6
|
+
* silently kill a tool. Instead we trim what is provably redundant:
|
|
7
|
+
* - tool description beyond `descLimit` chars
|
|
8
|
+
* - parameter description beyond `paramDescLimit` chars
|
|
9
|
+
* Both limits are conservative (500/120) so the selection signal survives —
|
|
10
|
+
* this is the TSCG "conservative tier" idea, not aggressive compression.
|
|
11
|
+
*
|
|
12
|
+
* Applied ONLY to dynamically-loaded tools; core tools are operator-chosen
|
|
13
|
+
* and left untouched. Off by default (`compressEnabled: false`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function compressTool(tool, opts = {}) {
|
|
17
|
+
const descLimit = opts.descLimit ?? 500;
|
|
18
|
+
const paramDescLimit = opts.paramDescLimit ?? 120;
|
|
19
|
+
const out = { ...tool };
|
|
20
|
+
|
|
21
|
+
if (typeof out.description === "string" && out.description.length > descLimit) {
|
|
22
|
+
out.description = out.description.slice(0, descLimit) + " …";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const params = out.parameters;
|
|
26
|
+
if (params && typeof params === "object" && params.properties) {
|
|
27
|
+
const props = {};
|
|
28
|
+
for (const [key, raw] of Object.entries(params.properties)) {
|
|
29
|
+
const p = { ...raw };
|
|
30
|
+
if (typeof p.description === "string" && p.description.length > paramDescLimit) {
|
|
31
|
+
p.description = p.description.slice(0, paramDescLimit) + " …";
|
|
32
|
+
}
|
|
33
|
+
props[key] = p;
|
|
34
|
+
}
|
|
35
|
+
out.parameters = { ...params, properties: props };
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-tool-folder",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fold the DSH tool surface per request: per-agent core + BM25 dynamic loading + heat feedback. Shrinks schema tokens 80-90% while keeping selection accuracy.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh-plugin",
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"tool-selection",
|
|
11
|
+
"token-optimization",
|
|
12
|
+
"bm25"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|