dsh-tool-folder 0.2.5 → 0.3.2
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/LICENSE +21 -0
- package/README.md +109 -2
- package/lib/bm25.js +207 -127
- package/lib/embed-remote.js +193 -0
- package/lib/expand.js +276 -0
- package/lib/index.js +1261 -983
- package/lib/rerank.js +165 -0
- package/lib/semantic.js +273 -102
- package/lib/static-embed/meta.json +10 -0
- package/lib/static-embed/scales.bin +0 -0
- package/lib/static-embed/vectors.bin +0 -0
- package/lib/static-embed/vocab.json +1 -0
- package/lib/static-embed.js +195 -0
- package/package.json +55 -47
package/lib/index.js
CHANGED
|
@@ -1,983 +1,1261 @@
|
|
|
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
|
-
* `tools/pre-execute` — listener receives (exec, next); return
|
|
21
|
-
* { kind: "deny", reason } to refuse, or call next() to allow.
|
|
22
|
-
* exec = { callId, name, arguments, agent, signal } (no .deny() method).
|
|
23
|
-
* `tools/post-execute` — listener receives (exec, result, next); fires once
|
|
24
|
-
* per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
|
|
25
|
-
* loop. (`agent/pre-step` carries { messages, position, signal } only —
|
|
26
|
-
* no toolCalls — so it is useless for feedback.)
|
|
27
|
-
*
|
|
28
|
-
* Safety: any error falls back to the untouched assembly. Performance: BM25
|
|
29
|
-
* in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
|
|
30
|
-
*/
|
|
31
|
-
import fs from "node:fs";
|
|
32
|
-
import path from "node:path";
|
|
33
|
-
import z from "@deepseek-ai/schemastery";
|
|
34
|
-
import { buildIndex, search } from "./bm25.js";
|
|
35
|
-
import { compressTool, normalizeDescription } from "./schema.js";
|
|
36
|
-
import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, missingMetaTools } from "./metrics.js";
|
|
37
|
-
import { tierForBudget } from "./budget.js";
|
|
38
|
-
import { readState, writeState } from "./storage.js";
|
|
39
|
-
import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.description("
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
"
|
|
122
|
-
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const s =
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
if (
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
.
|
|
707
|
-
(
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
.
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
)
|
|
724
|
-
.
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
// an
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
+
* `tools/pre-execute` — listener receives (exec, next); return
|
|
21
|
+
* { kind: "deny", reason } to refuse, or call next() to allow.
|
|
22
|
+
* exec = { callId, name, arguments, agent, signal } (no .deny() method).
|
|
23
|
+
* `tools/post-execute` — listener receives (exec, result, next); fires once
|
|
24
|
+
* per executed tool. exec.name/exec.arguments feed the L3/L4 feedback
|
|
25
|
+
* loop. (`agent/pre-step` carries { messages, position, signal } only —
|
|
26
|
+
* no toolCalls — so it is useless for feedback.)
|
|
27
|
+
*
|
|
28
|
+
* Safety: any error falls back to the untouched assembly. Performance: BM25
|
|
29
|
+
* in-memory + sync; a maxFoldMs hard cap keeps the full list if we overrun.
|
|
30
|
+
*/
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import z from "@deepseek-ai/schemastery";
|
|
34
|
+
import { buildIndex, search, tokenize } from "./bm25.js";
|
|
35
|
+
import { compressTool, normalizeDescription } from "./schema.js";
|
|
36
|
+
import { selectionCoverage, isHotTool, prioritizeExact, touchWarm, evictWarm, missingMetaTools } from "./metrics.js";
|
|
37
|
+
import { tierForBudget } from "./budget.js";
|
|
38
|
+
import { readState, writeState } from "./storage.js";
|
|
39
|
+
import { expandQuery, fallbackCatalog, createSearchCache, recordDiscoveries, serializeDiscoveries, deserializeDiscoveries, siblingClosure, buildSchemaResponse } from "./ux.js";
|
|
40
|
+
// v0.3.1:增强词表扩展(中英双向 + 反向别名)。与 ux.js 的 EXPAND 并存互补:
|
|
41
|
+
// ux 的字典是「一个中文词 → 一个英文词」,这里是「一个意图词 → 多个中英同义词」。
|
|
42
|
+
import { expandQuery as expandQueryTokens } from "./expand.js";
|
|
43
|
+
// v0.3.1:静态嵌入语义腿(离线、零推理)。构建脚本 tools/build-static-embed.py。
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
import { openStaticEmbed, cosine } from "./static-embed.js";
|
|
46
|
+
import { ChainGuard, verdict, isExecTool } from "./chainguard.js";
|
|
47
|
+
import { matchCategory } from "./category.js";
|
|
48
|
+
import { toonifyValue } from "./toonify.js";
|
|
49
|
+
import {
|
|
50
|
+
configureSemantic,
|
|
51
|
+
buildSemanticIndex,
|
|
52
|
+
searchSemantic,
|
|
53
|
+
searchSemanticCached,
|
|
54
|
+
embedQueryCached,
|
|
55
|
+
getCachedQueryVector,
|
|
56
|
+
rrfFuse,
|
|
57
|
+
adaptiveBmWeight,
|
|
58
|
+
DEFAULT_SEM_WEIGHT,
|
|
59
|
+
DEFAULT_BM_WEIGHT,
|
|
60
|
+
} from "./semantic.js";
|
|
61
|
+
import { rerankByBody, toolBodyText } from "./rerank.js";
|
|
62
|
+
import { remoteEmbedState } from "./embed-remote.js";
|
|
63
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
64
|
+
|
|
65
|
+
export const name = "tool-folder";
|
|
66
|
+
export const inject = ["systemPrompt", "agents", "tools"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Schemastery configuration schema — renders the plugin's settings form in the
|
|
70
|
+
* DSH settings UI (per cordis `runtime.Config["~standard"]`). Mirrors DEFAULTS
|
|
71
|
+
* below; the UI exposes every toggle/knob this plugin has.
|
|
72
|
+
* Same pattern as @deepseek-ai/dsh-tool-todo (z.object + .default()).
|
|
73
|
+
*/
|
|
74
|
+
const COMPRESS_LEVELS = ["off", "light", "standard", "aggressive"];
|
|
75
|
+
export const Config = z.object({
|
|
76
|
+
enabled: z.boolean().default(true).description("总开关:启用工具折叠"),
|
|
77
|
+
core: z.array(z.string()).default([]).description("每轮恒显示的工具(平台核心,永不折叠)"),
|
|
78
|
+
perAgent: z.dict(z.object({
|
|
79
|
+
core: z.array(z.string()).default([]).description("该 agent 的常驻工具覆盖(未列出 agent 回退到全局 core)"),
|
|
80
|
+
})).default({}).description("按 agent 覆盖常驻工具:{\"standard\": {\"core\": [...]}}"),
|
|
81
|
+
deny: z.array(z.string()).default([]).description("禁用工具:精确名或 prefix* 前缀,命中不注入+拒绝执行"),
|
|
82
|
+
topK: z.number().min(0).max(50).default(6).description("BM25 动态检索每轮最多注入的工具数"),
|
|
83
|
+
hotThreshold: z.number().min(1).default(3).description("被折叠工具调用几次后自动晋升为常驻"),
|
|
84
|
+
hotWindowDays: z.number().min(0).max(365).default(3)
|
|
85
|
+
.description("hot 热度的滑动窗口(天):窗口内达标才常驻,超窗自动衰减;0=永不过期"),
|
|
86
|
+
catalogEnabled: z.boolean().default(true).description("折叠安全网:把被藏工具列表写进提示词,模型知道它们存在"),
|
|
87
|
+
compressLevel: z.union(COMPRESS_LEVELS).default("off").description("L1 schema 压缩级别:off 不压缩 / light 裁描述 / standard 裁描述+参数说明 / aggressive 删可选参数"),
|
|
88
|
+
compressEnabled: z.boolean().default(false).description("兼容开关:compressLevel=off 且此开=true 时按 standard 处理"),
|
|
89
|
+
schemaToolEnabled: z.boolean().default(true).description("注册 tools_schema 元工具:按名称展开任意工具的完整参数 schema"),
|
|
90
|
+
normalizeDescriptions: z.boolean().default(false).description("描述标准化:注入消毒+首句提取+300 字符(只作用于动态段)"),
|
|
91
|
+
category: z.dict(z.array(z.string())).default({}).description("意图路由:{\"记忆/回忆\": [\"mcp__viking\"]},优先级 category > aliases > BM25"),
|
|
92
|
+
include: z.array(z.string()).default([]).description("白名单:[\"mcp__openhands*\"],空=全部(deny 仍优先,meta 工具豁免)"),
|
|
93
|
+
toonifyResults: z.boolean().default(false).description("长 JSON 结果紧凑化(删空字段/截长串,默认关)"),
|
|
94
|
+
toolSearchEnabled: z.boolean().default(true).description("注册 tools_search 元工具:检索被折叠的工具目录"),
|
|
95
|
+
firewallEnabled: z.boolean().default(true).description("ChainGuard 防火墙:高危命令硬拦截+外泄链检测"),
|
|
96
|
+
maxFoldMs: z.number().min(0).max(1000).default(50).description("折叠超时上限(ms),超时回退完整工具列表"),
|
|
97
|
+
feedbackFile: z.string().default("").description("反馈数据落盘路径(空=DSH_HOME/logs/tool-folder/)"),
|
|
98
|
+
aliases: z.dict(z.array(z.string())).default({}).description("中文意图→server 别名表"),
|
|
99
|
+
semanticEnabled: z.boolean().default(true).description("语义检索腿:tools_search 用 BM25+语义 加权 RRF 混合(本地静态嵌入优先 → 远程 embedding → 本地 Ollama → 纯 BM25)"),
|
|
100
|
+
staticEmbedDir: z.string().default("").description("静态嵌入目录(含 vocab.json/vectors.bin/meta.json)。留空则用插件自带 lib/static-embed;目录不存在时该腿自动跳过"),
|
|
101
|
+
ollamaBase: z.string().default("http://127.0.0.1:11434").description("Ollama 地址(远程 embedding 不可用时的本地兜底)"),
|
|
102
|
+
embedModel: z.string().default("bge-m3").description("本地 Ollama embedding 模型(兜底腿)"),
|
|
103
|
+
embedTimeoutMs: z.number().min(100).default(12000).description("embedding 请求超时(毫秒)"),
|
|
104
|
+
embedApiBase: z.string().default("https://ark.cn-beijing.volces.com/api/plan/v3").description("远程 embedding 端点(OpenAI 兼容;默认火山方舟)"),
|
|
105
|
+
embedApiModel: z.string().default("doubao-embedding-vision").description("远程 embedding 模型"),
|
|
106
|
+
embedApiKey: z.string().default("").description("远程 embedding key;留空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY"),
|
|
107
|
+
semanticWeight: z.number().min(0).max(20).default(3).description("RRF 中**远程**语义腿相对 BM25 的权重:1=等权,3=偏语义(默认),0=纯 BM25"),
|
|
108
|
+
staticWeight: z.number().min(0).max(20).default(0.15).description("RRF 中**本地静态嵌入**腿相对 BM25 的权重,默认 0.15。静态腿只负责补词法查不到的坑,压过词法会引入噪声(实测:留出集 w=0.15 → recall@3 0.621,w=2 → 0.517)"),
|
|
109
|
+
adaptiveBm: z.boolean().default(true).description("自适应 BM25 权重:查询里有真实英文锚点(工具名/server/关键词)时 BM25 权重 1,纯中文查询降到 bmWeight(实测 6/18→11/18)"),
|
|
110
|
+
bmWeight: z.number().min(0).max(5).default(0.25).description("纯中文查询下 BM25 的 RRF 权重(adaptiveBm=false 时固定用这个值)"),
|
|
111
|
+
bodyRerank: z.boolean().default(false).description("正文精排:在召回候选集内用完整描述+参数 schema 重排(借鉴 SkillRouter 两阶段检索)。实测本语料 Hit@1 持平、Hit@3 -1,故默认关"),
|
|
112
|
+
bodyRerankWeight: z.number().min(0).max(1).default(0.45).description("正文精排权重:final=(1-w)·元数据名次 + w·正文覆盖。v0.3.1 由 0.35 调到 0.45:eval/sweep.mjs 扫描 40 条标注查询,0.45 得 82.5% top-1(0.35 为 80.0%),0.50 起开始掉"),
|
|
113
|
+
bodyRerankTopN: z.number().min(1).max(50).default(12).description("参与正文重排的候选数(不扩大候选,只在召回集内重排)"),
|
|
114
|
+
semanticDynamic: z.boolean().default(true).description("语义腿参与每轮自动注入:只用已缓存的查询向量,零额外延迟(首次查询由后台预热)"),
|
|
115
|
+
debugDump: z.string().default("").description("调试:把当前工具语料(名/描述/schema)dump 到该路径,用于离线评测(空=关闭)"),
|
|
116
|
+
disclosureBudget: z.number().min(0).max(200000).default(0)
|
|
117
|
+
.description("分级披露预算(字节):0=关闭。开启后可见工具 schema 估算字节超预算时,动态段按 完整schema→name+desc→仅name→折叠进catalog 逐级降级(core/hot 永不降级)"),
|
|
118
|
+
maxWarmTools: z.number().min(0).max(50).default(0)
|
|
119
|
+
.description("warm LRU 上限:tools_search/schema 发现或折叠后被调用的工具进入 warm 集合,下一轮注入(完整 schema);达上限逐出最久未用;0=关闭(默认关,无配置升级逐字节等价,显式开启才启用)"),
|
|
120
|
+
fallbackCatalogSize: z.number().min(0).max(200).default(30)
|
|
121
|
+
.description("P0: tools_search 零匹配时回退的轻目录条数(name+80字描述,名字典序);0=关闭回退(返回空=旧行为)"),
|
|
122
|
+
queryExpandEnabled: z.boolean().default(true)
|
|
123
|
+
.description("P0: 中文意图→英文同义词扩展(只加词不改原词,扩大 BM25/语义召回;精确名匹配不受影响)"),
|
|
124
|
+
closureSize: z.number().min(0).max(20).default(0)
|
|
125
|
+
.description("P1: tools_schema 展开时附带同 server 兄弟工具的轻目录条数(name+120字描述,名字典序);0=关闭(默认关=无配置升级输出逐字段等价,显式开启才启用,与 maxWarmTools 先例一致)"),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const DEFAULTS = {
|
|
129
|
+
enabled: true,
|
|
130
|
+
perAgent: {},
|
|
131
|
+
core: [],
|
|
132
|
+
deny: [], // never inject + refuse execution (exact name or "prefix*")
|
|
133
|
+
topK: 6,
|
|
134
|
+
hotThreshold: 3,
|
|
135
|
+
hotWindowDays: 3, // v0.2.1: sliding-window heat decay (0 = never expire)
|
|
136
|
+
catalogEnabled: true, // safety net: folded tools stay discoverable by name
|
|
137
|
+
compressEnabled: false, // deprecated compat: compressLevel "off" + true → "standard"
|
|
138
|
+
compressLevel: "off", // "off" | "light" | "standard" | "aggressive"
|
|
139
|
+
schemaToolEnabled: true, // P0-1: register the tools_schema meta tool
|
|
140
|
+
normalizeDescriptions: false, // P1-2: sanitize+first-sentence+300 dyn descriptions
|
|
141
|
+
category: {}, // P1-1: { "记忆/回忆": ["mcp__viking"], ... } intent routing
|
|
142
|
+
include: [], // P2-1: whitelist ("prefix*" or exact name); empty = all (minus deny)
|
|
143
|
+
toonifyResults: false, // P2-2: compact long JSON results (default off)
|
|
144
|
+
toolSearchEnabled: true,
|
|
145
|
+
firewallEnabled: true, // ChainGuard: high-risk hard block + exfil chain warn
|
|
146
|
+
maxFoldMs: 50,
|
|
147
|
+
feedbackFile: "",
|
|
148
|
+
aliases: {
|
|
149
|
+
"mcp__viking": ["记忆", "记住", "回忆", "remember", "memory"],
|
|
150
|
+
"mcp__openhands": ["写代码", "编程", "编码", "开发", "coding", "code", "openhands", "委派", "delegate"],
|
|
151
|
+
"mcp__context7": ["文档", "api 文档", "库文档", "documentation", "docs", "context7"],
|
|
152
|
+
"mcp__deeptutor": ["学习", "课程", "教学", "tutor", "study", "learn"],
|
|
153
|
+
"mcp__reasonix": ["推理", "复杂任务", "深度思考", "reasoning", "reasonix"],
|
|
154
|
+
"mcp__open-design": ["设计", "design"],
|
|
155
|
+
"mcp__serena": ["代码搜索", "搜代码", "search code", "serena"],
|
|
156
|
+
},
|
|
157
|
+
semanticEnabled: true, // v0.3.1: 静态嵌入优先 → 远程 → Ollama → BM25
|
|
158
|
+
staticEmbedDir: "", // 空 = <插件目录>/lib/static-embed
|
|
159
|
+
ollamaBase: "http://127.0.0.1:11434",
|
|
160
|
+
embedModel: "bge-m3",
|
|
161
|
+
embedTimeoutMs: 12000, // v0.3.0: 远程 embedding 首页可能较慢,8s 偏紧
|
|
162
|
+
embedApiBase: "https://ark.cn-beijing.volces.com/api/plan/v3", // v0.3.0 远程语义腿
|
|
163
|
+
embedApiModel: "doubao-embedding-vision",
|
|
164
|
+
embedApiKey: "", // 空则读 DSH_EMBED_KEY / OPENVIKING_ARK_KEY
|
|
165
|
+
semanticWeight: 3, // v0.3.0: RRF 远程语义权重(1=等权)
|
|
166
|
+
staticWeight: 0.15, // v0.3.2: 本地静态嵌入权重,压测最优(1:0.15)
|
|
167
|
+
adaptiveBm: true, // v0.3.0: 纯中文查询自动降 BM25 权重
|
|
168
|
+
bmWeight: 0.25, // v0.3.0: 纯中文查询下的 BM25 权重(实测 1:1 融合只有 8/18)
|
|
169
|
+
bodyRerank: false, // v0.3.0: 实测 Hit@1 持平 / Hit@3 -1,默认关,可显式开
|
|
170
|
+
bodyRerankWeight: 0.45,
|
|
171
|
+
bodyRerankTopN: 12,
|
|
172
|
+
semanticDynamic: true, // v0.3.0: 语义腿参与自动注入(只用缓存查询向量)
|
|
173
|
+
debugDump: "", // v0.3.0: 工具语料 dump(离线评测用);也认 DSH_TOOL_FOLDER_DUMP 环境变量
|
|
174
|
+
disclosureBudget: 0, // v0.2.2: 分级披露预算(0=关闭)
|
|
175
|
+
maxWarmTools: 0, // v0.2.2: warm LRU 上限(0=关闭,默认关=无配置升级逐字节等价)
|
|
176
|
+
fallbackCatalogSize: 30, // P0: 零匹配回退轻目录条数(0=关闭)
|
|
177
|
+
queryExpandEnabled: true, // P0: 中文→英文 query 扩展
|
|
178
|
+
closureSize: 0, // P1: tools_schema 同 server 兄弟闭包(默认关=逐字段等价,显式开启)
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function feedbackPath(cfg) {
|
|
182
|
+
if (cfg.feedbackFile) return cfg.feedbackFile;
|
|
183
|
+
// Host-side plugins run in the real Node runtime (not the dynamic sandbox),
|
|
184
|
+
// so process.env is available. Guarded anyway: fall back to a relative path.
|
|
185
|
+
try {
|
|
186
|
+
const home = process.env.DSH_HOME || "";
|
|
187
|
+
if (home) return `${home}/logs/tool-folder/feedback.json`;
|
|
188
|
+
return `${process.env.APPDATA || "."}/dsh/logs/tool-folder/feedback.json`;
|
|
189
|
+
} catch {
|
|
190
|
+
return "tool-folder/feedback.json";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function loadFeedback(file, log) {
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
197
|
+
} catch (e) {
|
|
198
|
+
log?.warn?.("feedback load failed (%s) — starting empty", e?.message);
|
|
199
|
+
}
|
|
200
|
+
return { calls: {}, sessions: [] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function persistFeedback(file, fb, log) {
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
206
|
+
fs.writeFileSync(file, JSON.stringify(fb, null, 2), "utf8");
|
|
207
|
+
} catch (e) {
|
|
208
|
+
log?.warn?.("feedback persist failed (%s) — non-fatal", e?.message);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Serialize an exec.arguments payload (object) into text for firewall verdicts. */
|
|
213
|
+
function execArgumentsText(args) {
|
|
214
|
+
if (args == null) return "";
|
|
215
|
+
if (typeof args === "string") return args;
|
|
216
|
+
try {
|
|
217
|
+
return JSON.stringify(args);
|
|
218
|
+
} catch {
|
|
219
|
+
return String(args);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractQuery(context) {
|
|
224
|
+
const signal = context && context.signal;
|
|
225
|
+
if (!signal) return "";
|
|
226
|
+
try {
|
|
227
|
+
const s = signal.userMessage?.content ?? signal.query ?? signal.prompt;
|
|
228
|
+
if (typeof s === "string") return s;
|
|
229
|
+
if (Array.isArray(signal.messages)) {
|
|
230
|
+
for (let i = signal.messages.length - 1; i >= 0; i--) {
|
|
231
|
+
const m = signal.messages[i];
|
|
232
|
+
if (m && m.role === "user" && typeof m.content === "string") return m.content;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
/* unknown shape */
|
|
237
|
+
}
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function serverOf(name) {
|
|
242
|
+
// "mcp__github__get_repo" → "mcp__github"; "openhands_create_conversation" → "openhands";
|
|
243
|
+
// native tools with underscores ("read_file", "search_for_pattern") → "native".
|
|
244
|
+
const m = name.match(/^(mcp__[^_]+(?:_[^_]+)?)/);
|
|
245
|
+
if (m) return m[1];
|
|
246
|
+
if (/^openhands_/.test(name)) return "openhands";
|
|
247
|
+
return "native";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function summarizeCaps(tools) {
|
|
251
|
+
// Pull the first few distinct capability keywords from descriptions.
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const caps = [];
|
|
254
|
+
for (const t of tools) {
|
|
255
|
+
const d = (t.description || "").replace(/\s+/g, " ").trim();
|
|
256
|
+
const head = d.split(/[—,;:]/)[0].slice(0, 60);
|
|
257
|
+
if (head && !seen.has(head)) {
|
|
258
|
+
seen.add(head);
|
|
259
|
+
caps.push(head);
|
|
260
|
+
}
|
|
261
|
+
if (caps.length >= 3) break;
|
|
262
|
+
}
|
|
263
|
+
return caps.join(" | ") || "capabilities unknown";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function catalogText(tools) {
|
|
267
|
+
// Affordance fix: group folded tools by server and emit a capability
|
|
268
|
+
// summary per server (not a callable-looking name list). The model must
|
|
269
|
+
// expand arguments via tools_schema before calling a folded tool.
|
|
270
|
+
const byServer = new Map();
|
|
271
|
+
for (const t of tools) {
|
|
272
|
+
const s = serverOf(t.name);
|
|
273
|
+
if (!byServer.has(s)) byServer.set(s, []);
|
|
274
|
+
byServer.get(s).push(t);
|
|
275
|
+
}
|
|
276
|
+
const lines = [...byServer.entries()]
|
|
277
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
278
|
+
.map(([server, ts]) => `- [${server}]: ${ts.length} tools — ${summarizeCaps(ts)}`);
|
|
279
|
+
return [
|
|
280
|
+
"Additional tools exist but are not loaded for this request. " +
|
|
281
|
+
"They are grouped by server below. To call one: first expand its " +
|
|
282
|
+
"arguments with tools_schema (exact tool name), then call it by name.",
|
|
283
|
+
...lines,
|
|
284
|
+
].join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function apply(ctx, config) {
|
|
288
|
+
const cfg = { ...DEFAULTS, ...(config || {}) };
|
|
289
|
+
|
|
290
|
+
// T01: normalize the v0.1.5 config surface right after the DEFAULTS merge.
|
|
291
|
+
// - compressLevel accepts only the four valid tiers; anything else → "off".
|
|
292
|
+
// - compressEnabled is the deprecated compat switch: "off" + true → "standard".
|
|
293
|
+
// - include patterns are trimmed/non-empty; empty list = include-all.
|
|
294
|
+
// - category must be a plain object; malformed values are ignored.
|
|
295
|
+
const VALID_LEVELS = ["off", "light", "standard", "aggressive"];
|
|
296
|
+
const compressLevel = VALID_LEVELS.includes(cfg.compressLevel) ? cfg.compressLevel : "off";
|
|
297
|
+
const effectiveLevel = compressLevel !== "off" ? compressLevel : (cfg.compressEnabled ? "standard" : "off");
|
|
298
|
+
const includePatterns = (cfg.include || []).map((n) => String(n).trim()).filter(Boolean);
|
|
299
|
+
const includeAll = includePatterns.length === 0;
|
|
300
|
+
const categoryCfg = (cfg.category && typeof cfg.category === "object") ? cfg.category : {};
|
|
301
|
+
|
|
302
|
+
const logger = ctx.logger("tool-folder");
|
|
303
|
+
if (!cfg.enabled) {
|
|
304
|
+
logger.info("disabled by config");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 语义腿(v0.3.0):远程 embedding 优先,本地 Ollama 兜底。
|
|
309
|
+
// 索引在**后台**构建(首次约 30-40s,之后磁盘缓存命中 <100ms),
|
|
310
|
+
// 任何热路径都只读"已就绪的索引",绝不阻塞 assemble 或工具调用。
|
|
311
|
+
configureSemantic({
|
|
312
|
+
ollamaBase: cfg.ollamaBase,
|
|
313
|
+
embedModel: cfg.embedModel,
|
|
314
|
+
embedTimeoutMs: cfg.embedTimeoutMs,
|
|
315
|
+
remoteBase: cfg.embedApiBase,
|
|
316
|
+
remoteModel: cfg.embedApiModel,
|
|
317
|
+
remoteKey: cfg.embedApiKey,
|
|
318
|
+
});
|
|
319
|
+
let semIndex = null; // 已就绪的索引 {vectors, available}
|
|
320
|
+
let semIndexPromise = null;
|
|
321
|
+
let semStamp = "";
|
|
322
|
+
|
|
323
|
+
// ---- v0.3.1 本地静态嵌入语义腿(离线、零推理、零网络)----
|
|
324
|
+
// 产物见 tools/build-static-embed.py;目录缺失时整条腿自动跳过,
|
|
325
|
+
// 行为与 v0.3.0 一致(远程 → Ollama → 纯 BM25)。
|
|
326
|
+
const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
327
|
+
const staticDir = cfg.staticEmbedDir
|
|
328
|
+
? path.resolve(cfg.staticEmbedDir)
|
|
329
|
+
: path.join(LIB_DIR, "static-embed");
|
|
330
|
+
let localEmb = null; // undefined=未尝试 / false=不可用 / object=可用
|
|
331
|
+
let localVecs = null;
|
|
332
|
+
let localStamp = "";
|
|
333
|
+
|
|
334
|
+
const ensureLocalSemantic = () => {
|
|
335
|
+
if (!cfg.semanticEnabled) return null;
|
|
336
|
+
if (localEmb === null || localEmb === undefined) {
|
|
337
|
+
try {
|
|
338
|
+
localEmb = openStaticEmbed(staticDir);
|
|
339
|
+
} catch {
|
|
340
|
+
localEmb = false;
|
|
341
|
+
}
|
|
342
|
+
if (localEmb && localEmb.available) {
|
|
343
|
+
logger.info(
|
|
344
|
+
"static embedding ready: %d tokens × %d dims (%.1f MB) model=%s",
|
|
345
|
+
localEmb.count, localEmb.dim, localEmb.bytes / 1048576, localEmb.model || "?",
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
logger.info("static embedding absent at %s — falling back to remote/ollama", staticDir);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (!localEmb || !localEmb.available) return null;
|
|
352
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
353
|
+
if (localVecs && localStamp === stamp) return localVecs;
|
|
354
|
+
localStamp = stamp;
|
|
355
|
+
const m = new Map();
|
|
356
|
+
for (const d of semanticDocs()) {
|
|
357
|
+
const v = localEmb.embed(d.text);
|
|
358
|
+
if (v) m.set(d.id, v);
|
|
359
|
+
}
|
|
360
|
+
localVecs = m;
|
|
361
|
+
return localVecs;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// 文档正文按工具集缓存:toolBodyText 要拼描述 + 全部参数,243 个工具每轮重算
|
|
365
|
+
// 会给 assemble 热路径带来无谓开销。
|
|
366
|
+
let semDocsCache = null;
|
|
367
|
+
let semDocsStamp = "";
|
|
368
|
+
const semanticDocs = () => {
|
|
369
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
370
|
+
if (semDocsCache && semDocsStamp === stamp) return semDocsCache;
|
|
371
|
+
semDocsStamp = stamp;
|
|
372
|
+
semDocsCache = allTools.map((t) => ({ id: t.name, text: toolBodyText(t) }));
|
|
373
|
+
return semDocsCache;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** 触发后台构建(幂等、非阻塞)。工具集变化时按 stamp 重建。 */
|
|
377
|
+
const ensureSemanticIndex = () => {
|
|
378
|
+
if (!cfg.semanticEnabled || allTools.length === 0) return;
|
|
379
|
+
const stamp = `${allTools.length}\u0001${allTools[0]?.name || ""}\u0001${allTools[allTools.length - 1]?.name || ""}`;
|
|
380
|
+
if (semIndexPromise && stamp === semStamp) return;
|
|
381
|
+
semStamp = stamp;
|
|
382
|
+
semIndexPromise = (async () => {
|
|
383
|
+
const t0 = Date.now();
|
|
384
|
+
const docs = semanticDocs();
|
|
385
|
+
// dump 放在构建之前:语料采集不该依赖 embedding 是否成功
|
|
386
|
+
const dumpPath = cfg.debugDump || process.env.DSH_TOOL_FOLDER_DUMP || "";
|
|
387
|
+
if (dumpPath) {
|
|
388
|
+
try {
|
|
389
|
+
fs.mkdirSync(path.dirname(dumpPath), { recursive: true });
|
|
390
|
+
fs.writeFileSync(
|
|
391
|
+
dumpPath,
|
|
392
|
+
JSON.stringify({ ts: Date.now(), count: allTools.length, tools: allTools }, null, 2),
|
|
393
|
+
"utf8",
|
|
394
|
+
);
|
|
395
|
+
logger.info("debug dump written: %s", dumpPath);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
logger.warn("debug dump failed (%s)", e?.message);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
let built;
|
|
401
|
+
try {
|
|
402
|
+
built = await buildSemanticIndex(docs, cfg.embedTimeoutMs, {
|
|
403
|
+
onProgress: (done, total) => {
|
|
404
|
+
if (done === total || done % 40 === 0) {
|
|
405
|
+
logger.info("semantic index progress %d/%d", done, total);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
} catch (e) {
|
|
410
|
+
logger.warn("semantic index build failed (%s) — BM25 only", e?.message);
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const st = remoteEmbedState();
|
|
414
|
+
logger.info(
|
|
415
|
+
"semantic index ready: %d/%d vectors (embedded=%d reused=%d) in %dms | provider=%s",
|
|
416
|
+
built.vectors.size, docs.length, built.embedded, built.reused, Date.now() - t0,
|
|
417
|
+
st.available ? "remote" : "ollama/offline",
|
|
418
|
+
);
|
|
419
|
+
if (!built.available && !built.vectors.size) {
|
|
420
|
+
logger.warn("semantic leg unavailable (no vectors) — tools_search degrades to BM25");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
semIndex = built;
|
|
424
|
+
return built;
|
|
425
|
+
})();
|
|
426
|
+
// 后台跑,别让未处理的 rejection 冒泡
|
|
427
|
+
semIndexPromise.catch?.(() => {});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// 查询向量后台预热(节流 15s):让 semanticDynamic 在下一轮就能用缓存
|
|
431
|
+
let lastPrefetch = 0;
|
|
432
|
+
const prefetchQuery = (query) => {
|
|
433
|
+
if (!cfg.semanticEnabled || !cfg.semanticDynamic || !query) return;
|
|
434
|
+
if (getCachedQueryVector(query)) return;
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
if (now - lastPrefetch < 15_000) return;
|
|
437
|
+
lastPrefetch = now;
|
|
438
|
+
embedQueryCached(query, cfg.embedTimeoutMs).catch(() => {});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// 首次 assemble 之后自动预热语义索引(allTools 只在 assemble 里才有值,
|
|
442
|
+
// 启动瞬间是空的)。每 15s 探一次,最多 5 分钟;unref 保证不阻止进程退出。
|
|
443
|
+
// 这样第一条 tools_search 之前索引通常已就绪,且不占启动时间。
|
|
444
|
+
let warmTicks = 0;
|
|
445
|
+
const warmTimer = setInterval(() => {
|
|
446
|
+
warmTicks += 1;
|
|
447
|
+
if (semIndex || semIndexPromise || warmTicks > 20) {
|
|
448
|
+
clearInterval(warmTimer);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (allTools.length) ensureSemanticIndex();
|
|
452
|
+
}, 15_000);
|
|
453
|
+
warmTimer.unref?.();
|
|
454
|
+
|
|
455
|
+
const fbPath = feedbackPath(cfg);
|
|
456
|
+
const feedback = loadFeedback(fbPath, logger);
|
|
457
|
+
let index = null;
|
|
458
|
+
let indexStamp = null;
|
|
459
|
+
let allTools = [];
|
|
460
|
+
let lastVisible = { agentId: "", names: [], ts: 0 };
|
|
461
|
+
const disposers = [];
|
|
462
|
+
const chain = new ChainGuard({ window: 30 }); // firewall: exfil-chain + high-risk
|
|
463
|
+
|
|
464
|
+
/* ------------------------------------------------------------------ */
|
|
465
|
+
/* v0.2.2: warm LRU + 配置三级持久化 */
|
|
466
|
+
/* ------------------------------------------------------------------ */
|
|
467
|
+
// warm:Map<name, {last}>,LRU 顺序由 last 单调递增序号决定。被
|
|
468
|
+
// tools_search 搜到 / tools_schema 展开 / 折叠后仍被调用的工具进入集合,
|
|
469
|
+
// 下一轮注入完整 schema。maxWarmTools=0 时整个 warm 失效(仍不 touch)。
|
|
470
|
+
const warm = new Map();
|
|
471
|
+
const warmEnabled = cfg.maxWarmTools > 0;
|
|
472
|
+
const touchWarmNames = (names) => {
|
|
473
|
+
if (!warmEnabled || !Array.isArray(names) || names.length === 0) return;
|
|
474
|
+
touchWarm(warm, names, Date.now(), cfg.maxWarmTools);
|
|
475
|
+
persistWarmState();
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// 三级持久化:① feedbackPath 同目录(首选)→ ② ~/.dsh/state/ → ③ 进程内。
|
|
479
|
+
// ⚠️ 绝不写 cordis.patch.yml(宿主配置,插件只读宿主、只落自有 state 文件)。
|
|
480
|
+
const primaryStateFile = () => path.join(path.dirname(fbPath), "tool-folder-state.json");
|
|
481
|
+
const fallbackStateFile = () => {
|
|
482
|
+
const home = process.env.DSH_HOME || process.env.USERPROFILE || process.env.HOME || "";
|
|
483
|
+
return home ? `${home}/.dsh/state/tool-folder-state.json` : "tool-folder-state.json";
|
|
484
|
+
};
|
|
485
|
+
let activeStateFile = primaryStateFile();
|
|
486
|
+
const restoreWarm = () => {
|
|
487
|
+
if (!warmEnabled) return; // 关闭态绝不载入磁盘残留(Spec 2: maxWarmTools=0=彻底关闭)
|
|
488
|
+
let cloud = readState(activeStateFile, null);
|
|
489
|
+
if (!cloud) {
|
|
490
|
+
activeStateFile = fallbackStateFile();
|
|
491
|
+
cloud = readState(activeStateFile, null);
|
|
492
|
+
}
|
|
493
|
+
if (cloud && Array.isArray(cloud.warm)) {
|
|
494
|
+
for (const [name, meta] of cloud.warm) {
|
|
495
|
+
if (typeof name === "string" && meta && typeof meta.last === "number") {
|
|
496
|
+
warm.set(name, { last: meta.last });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
evictWarm(warm, cfg.maxWarmTools); // 载入即按上限裁剪
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const persistWarmState = () => {
|
|
503
|
+
if (!warmEnabled) return;
|
|
504
|
+
// merge 写:与 discoveries 共用 state 文件,整写会互抹(Standards 轴 P1)。
|
|
505
|
+
const payload = { ...readState(activeStateFile, {}), warm: [...warm.entries()], ts: Date.now() };
|
|
506
|
+
let ok = writeState(activeStateFile, payload);
|
|
507
|
+
if (!ok) {
|
|
508
|
+
// 首选目录不可写 → 降到 ~/.dsh/state/
|
|
509
|
+
activeStateFile = fallbackStateFile();
|
|
510
|
+
writeState(activeStateFile, payload);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
restoreWarm();
|
|
514
|
+
|
|
515
|
+
// P0-④: 发现率统计(独立于 warm 门控——maxWarmTools=0 时统计照常工作)。
|
|
516
|
+
// 与 warm 共用同一个 state 文件;落盘节流 30s(工具输出非注入面,频率低)。
|
|
517
|
+
let discoveries = new Map();
|
|
518
|
+
let lastPersistTs = 0;
|
|
519
|
+
const restoreDiscoveries = () => {
|
|
520
|
+
let cloud = readState(activeStateFile, null);
|
|
521
|
+
if (!cloud) {
|
|
522
|
+
activeStateFile = fallbackStateFile();
|
|
523
|
+
cloud = readState(activeStateFile, null);
|
|
524
|
+
}
|
|
525
|
+
discoveries = deserializeDiscoveries(cloud && Array.isArray(cloud.discoveries) ? cloud.discoveries : null);
|
|
526
|
+
};
|
|
527
|
+
const persistDiscoveries = () => {
|
|
528
|
+
const now = Date.now();
|
|
529
|
+
if (now - lastPersistTs < 30000) return; // 节流:30s 内只写一次
|
|
530
|
+
lastPersistTs = now;
|
|
531
|
+
// merge 写:与 warm 共用 state 文件,整写会互抹(Standards 轴 P1)。
|
|
532
|
+
const payload = { ...readState(activeStateFile, {}), discoveries: serializeDiscoveries(discoveries), ts: now };
|
|
533
|
+
let ok = writeState(activeStateFile, payload);
|
|
534
|
+
if (!ok) {
|
|
535
|
+
activeStateFile = fallbackStateFile();
|
|
536
|
+
writeState(activeStateFile, payload);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
restoreDiscoveries();
|
|
540
|
+
if (discoveries.size > 0) {
|
|
541
|
+
const top = serializeDiscoveries(discoveries, 5).map((e) => `${e.name}(${e.count})`).join(", ");
|
|
542
|
+
logger.info("discovery stats restored: %d tools, top: %s", discoveries.size, top);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// P0-②: 检索结果缓存(同 query 60s 内稳定;LRU 200 条;进程级共享)。
|
|
546
|
+
const searchCache = createSearchCache(200, 60000);
|
|
547
|
+
|
|
548
|
+
// serverOf is the module-level function defined above (mcp__server__tool →
|
|
549
|
+
// "mcp__server", openhands_* → "openhands", plain native tools → "native").
|
|
550
|
+
// P3-1 (2026-09-02): the previous apply-local copy (split("__").slice(0,2))
|
|
551
|
+
// let plain tools be their own "server", diverging from catalogText grouping
|
|
552
|
+
// and making sibling closure useless for native/openhands families. One
|
|
553
|
+
// definition, one口径.
|
|
554
|
+
|
|
555
|
+
// Pattern matching shared by deny (P0) and include (P2-1): exact name or
|
|
556
|
+
// "prefix*" matches any tool starting with that prefix.
|
|
557
|
+
function matchesAnyPattern(name, patterns) {
|
|
558
|
+
if (!name || !patterns || patterns.length === 0) return false;
|
|
559
|
+
const n = String(name);
|
|
560
|
+
for (const p of patterns) {
|
|
561
|
+
if (p.endsWith("*")) {
|
|
562
|
+
if (n.startsWith(p.slice(0, -1))) return true;
|
|
563
|
+
} else if (n === p) {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
// deny config: exact name, or "prefix*" matches any tool starting with it.
|
|
570
|
+
// Deny wins over core, include and the catalog (a denied tool is not listed).
|
|
571
|
+
const denyPatterns = (cfg.deny || []).map((n) => String(n).trim()).filter(Boolean);
|
|
572
|
+
const isDenied = (n) => matchesAnyPattern(n, denyPatterns);
|
|
573
|
+
|
|
574
|
+
// Per-server top-K: rank the matched server's tools by BM25 relevance with
|
|
575
|
+
// the matched intent keywords bridged into the query. When there is no
|
|
576
|
+
// lexical signal at all, fall back to a stable name-order subset so the
|
|
577
|
+
// intent route still helps. Used by both aliases and category routing.
|
|
578
|
+
function addPerServerTopK(dyn, tools, serverPrefix, bridgedQuery, maxK) {
|
|
579
|
+
const serverIndices = [];
|
|
580
|
+
for (let i = 0; i < tools.length; i++) {
|
|
581
|
+
if (tools[i].name.startsWith(serverPrefix)) serverIndices.push(i);
|
|
582
|
+
}
|
|
583
|
+
if (serverIndices.length === 0) return;
|
|
584
|
+
const k = Math.min(maxK, serverIndices.length);
|
|
585
|
+
let ranked = [];
|
|
586
|
+
if (index) {
|
|
587
|
+
ranked = search(index, bridgedQuery, serverIndices.length, {
|
|
588
|
+
filter: (i) => tools[i].name.startsWith(serverPrefix),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (ranked.length === 0) {
|
|
592
|
+
// No lexical signal at all → stable name order so the route still helps.
|
|
593
|
+
serverIndices.sort((a, b) => (tools[a].name < tools[b].name ? -1 : 1));
|
|
594
|
+
for (const i of serverIndices.slice(0, k)) dyn.add(tools[i].name);
|
|
595
|
+
} else {
|
|
596
|
+
for (const i of ranked.slice(0, k)) dyn.add(tools[i].name);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function buildDynamicSet(tools, query) {
|
|
601
|
+
const dyn = new Set();
|
|
602
|
+
if (!query) return dyn;
|
|
603
|
+
const q = query.toLowerCase();
|
|
604
|
+
// P1-1: category wins over aliases. When a category key matches, only the
|
|
605
|
+
// category's servers are routed per-server top-K; the aliases branch is
|
|
606
|
+
// skipped. The generic BM25 leg is still merged as a union afterwards.
|
|
607
|
+
const cat = matchCategory(q, categoryCfg);
|
|
608
|
+
if (cat.matched) {
|
|
609
|
+
for (const m of cat.matches) {
|
|
610
|
+
addPerServerTopK(dyn, tools, m.server, `${query} ${m.keywords.join(" ")}`, 3);
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
// aliases fallback — narrowed to a single representative tool per
|
|
614
|
+
// matching server (topK=1). A whole-server injection cancels the fold
|
|
615
|
+
// budget; the rest of the server stays discoverable via tools_search
|
|
616
|
+
// and the grouped catalog.
|
|
617
|
+
for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
|
|
618
|
+
const matched = keywords.filter((k) => q.includes(String(k).toLowerCase()));
|
|
619
|
+
if (matched.length === 0) continue;
|
|
620
|
+
addPerServerTopK(dyn, tools, prefix, `${query} ${matched.join(" ")}`, 1);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Generic BM25 last as a union (result set unchanged vs. previous order).
|
|
624
|
+
if (cfg.topK > 0 && index) {
|
|
625
|
+
for (const i of search(index, query, cfg.topK)) dyn.add(tools[i].name);
|
|
626
|
+
}
|
|
627
|
+
// v0.3.0: 语义腿参与自动注入。**只用已缓存的查询向量**——热路径绝不等网络,
|
|
628
|
+
// 所以这里不会拖慢 assemble,也不会撞 maxFoldMs。查询向量由 prefetchQuery
|
|
629
|
+
// 在后台预热(同一条消息的第二轮及以后通常已命中缓存)。
|
|
630
|
+
if (cfg.semanticDynamic && cfg.topK > 0 && semIndex?.available) {
|
|
631
|
+
try {
|
|
632
|
+
const hits = searchSemanticCached(semIndex.vectors, semanticDocs(), query);
|
|
633
|
+
for (const h of hits.slice(0, cfg.topK)) dyn.add(h.id);
|
|
634
|
+
} catch {
|
|
635
|
+
/* 语义腿失败绝不影响折叠 */
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return dyn;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// L4: `tools_search` — model-driven discovery of folded tools.
|
|
642
|
+
if (cfg.toolSearchEnabled) {
|
|
643
|
+
const outputSchema = {
|
|
644
|
+
type: "object",
|
|
645
|
+
additionalProperties: false,
|
|
646
|
+
properties: {
|
|
647
|
+
query: { type: "string", required: true },
|
|
648
|
+
results: {
|
|
649
|
+
type: "array",
|
|
650
|
+
required: true,
|
|
651
|
+
items: {
|
|
652
|
+
type: "object",
|
|
653
|
+
additionalProperties: false,
|
|
654
|
+
properties: {
|
|
655
|
+
name: { type: "string", required: true },
|
|
656
|
+
description: { type: "string", required: true },
|
|
657
|
+
server: { type: "string", required: true },
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
total: { type: "integer", required: true },
|
|
662
|
+
fallback: { type: "boolean", description: "true = 零匹配,结果回退为按名字典序的轻目录" },
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
const toolsSearch = defineTool({
|
|
666
|
+
name: "tools_search",
|
|
667
|
+
description:
|
|
668
|
+
"Search the full tool catalog for a task. Use when the tools loaded " +
|
|
669
|
+
"for this request may not cover what you need — e.g. a server-specific " +
|
|
670
|
+
"capability — and you want to know if a matching tool exists. " +
|
|
671
|
+
"Returns matching tool names and descriptions; you may then call any " +
|
|
672
|
+
"returned tool directly by name.",
|
|
673
|
+
parameters: {
|
|
674
|
+
query: {
|
|
675
|
+
type: "string",
|
|
676
|
+
required: true,
|
|
677
|
+
description: "Search keywords, e.g. 'openhands session' or 'context7 documentation'.",
|
|
678
|
+
},
|
|
679
|
+
limit: { type: "number", description: "Max results. Default 8." },
|
|
680
|
+
},
|
|
681
|
+
output: { schema: outputSchema, render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }] },
|
|
682
|
+
isConcurrencySafe: () => true,
|
|
683
|
+
async execute(args) {
|
|
684
|
+
// 整体 fail-open 兜底(Spec 轴 P2):任何未来 helper 意外抛错 → 空结果
|
|
685
|
+
// 而非硬失败,保持"折叠绝不误伤能力"的底线。
|
|
686
|
+
try {
|
|
687
|
+
const q = String(args.query || "");
|
|
688
|
+
const lim = args.limit ?? 8;
|
|
689
|
+
if (!index) return { query: q, results: [], total: 0 };
|
|
690
|
+
// P0-②: 同 query 内容指纹的结果缓存——重复搜索返回同一结果集(漂移的
|
|
691
|
+
// 最小修)。进程级共享(非单会话隔离);空 query("*" 语义)不走缓存。
|
|
692
|
+
const cacheKey = `${lim}|${q}|${indexStamp || ""}`;
|
|
693
|
+
if (q) {
|
|
694
|
+
const cached = searchCache.get(cacheKey);
|
|
695
|
+
if (cached) return cached;
|
|
696
|
+
}
|
|
697
|
+
// P0-③: 中文意图→英文同义词扩展(只加词不改原词;精确名匹配用原 q)。
|
|
698
|
+
// v0.3.1:在 ux 的单向 CN→EN 字典之上,再叠一层 expand.js 的意图词表
|
|
699
|
+
// (中英双向 + 反向别名)。实测 recall@3 0.725 → 1.000、MRR 0.672 → 0.888
|
|
700
|
+
// (eval/run.mjs,36 工具 / 40 查询)。零命中查询从 5 条降到 0 条。
|
|
701
|
+
// 两个形态同时保留,别混用:
|
|
702
|
+
// eqStr — 拼好的字符串,喂语义腿(它只接受原始文本,内部自己 embed)
|
|
703
|
+
// eq — token 数组,喂 BM25(避免二次分词把中文 bigram 拆散)
|
|
704
|
+
let eqStr = q;
|
|
705
|
+
let eq = q;
|
|
706
|
+
if (cfg.queryExpandEnabled !== false) {
|
|
707
|
+
const base = expandQuery(q); // 旧:返回字符串
|
|
708
|
+
const extra = expandQueryTokens(q, { aliases: cfg.aliases || {} }); // 新:token 数组
|
|
709
|
+
if (extra.length) {
|
|
710
|
+
eqStr = `${base} ${extra.join(" ")}`;
|
|
711
|
+
eq = tokenize(base).concat(extra);
|
|
712
|
+
} else {
|
|
713
|
+
eqStr = base;
|
|
714
|
+
eq = base;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// v0.2.1: exact-name > name-substring > BM25 priority (deterministic
|
|
718
|
+
// fallback chain, borrowed from Hermes-style tool search).
|
|
719
|
+
const bm25Hits = prioritizeExact(
|
|
720
|
+
allTools,
|
|
721
|
+
q,
|
|
722
|
+
search(index, eq, lim * 2).map((i) => ({ id: allTools[i].name })),
|
|
723
|
+
);
|
|
724
|
+
const byName = new Map(allTools.map((t) => [t.name, t]));
|
|
725
|
+
let fused = bm25Hits;
|
|
726
|
+
// 语义腿(v0.3.0):远程优先,加权 RRF。索引在后台构建——这里绝不等待
|
|
727
|
+
// 冷启动(否则一次搜索能卡 30s+),尚未就绪就先返回 BM25 结果,
|
|
728
|
+
// 同时由 ensureSemanticIndex 继续在后台补,下一次搜索自然用上。
|
|
729
|
+
if (cfg.semanticEnabled !== false) {
|
|
730
|
+
try {
|
|
731
|
+
// v0.3.1:本地静态嵌入优先。离线、零推理、毫秒级,且不依赖订阅。
|
|
732
|
+
// 只有本地缺失(没构建过 static-embed)才退回远程/Ollama。
|
|
733
|
+
const ls = ensureLocalSemantic();
|
|
734
|
+
// v0.3.2:查询向量为空(整句 token 全在词表外,或全是标点)时不能进融合。
|
|
735
|
+
// 否则所有文档余弦都是 0,排序退化为 Map 插入顺序,会给 topK 塞进
|
|
736
|
+
// 一批与查询无关的工具,比不加语义腿更糟。
|
|
737
|
+
const qv0 = ls && ls.size ? localEmb.embed(eqStr) : null;
|
|
738
|
+
const usedLocal = !!(qv0);
|
|
739
|
+
if (usedLocal) {
|
|
740
|
+
const qv = qv0;
|
|
741
|
+
const localHits = [];
|
|
742
|
+
for (const [id, v] of ls) localHits.push({ id, score: cosine(qv, v) });
|
|
743
|
+
localHits.sort((a, b) => b.score - a.score);
|
|
744
|
+
// v0.3.2:本地腿单列权重。实测(eval/sweep-static.mjs,40 标注 + 29 留出)
|
|
745
|
+
// 静态腿最佳配比是 bm25:static = 1:0.15 —— 静态腿的价值在于「补词法
|
|
746
|
+
// 查不到的坑」,而不在于压过词法本身。这一点和远程 bge-m3 腿不同:
|
|
747
|
+
// 后者质量更高,用 semanticWeight(3) 是对的;本地静态腿按 3 会被噪声主导
|
|
748
|
+
// (实测 w=2 时留出集 recall@3 从 0.621 掉到 0.517)。
|
|
749
|
+
// 因此本地腿用 staticWeight,且 bmWeight 固定 1:不叠加 adaptiveBm 的降权,
|
|
750
|
+
// 否则两处调整相乘(0.25×3=0.75 等效)偏离压测配置。
|
|
751
|
+
fused = rrfFuse(bm25Hits, localHits.slice(0, lim * 2), lim, {
|
|
752
|
+
semWeight: cfg.staticWeight,
|
|
753
|
+
bmWeight: 1,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (!usedLocal) ensureSemanticIndex();
|
|
757
|
+
if (!usedLocal && semIndex?.available) {
|
|
758
|
+
const semHits = await searchSemantic(semIndex.vectors, semanticDocs(), eqStr);
|
|
759
|
+
// 自适应 BM25 权重:纯中文查询下 BM25 只有 CJK bigram 噪声,
|
|
760
|
+
// 降权 0.25;有真实英文锚点(工具名/server/关键词)时保持 1。
|
|
761
|
+
const topTool = bm25Hits[0] ? byName.get(bm25Hits[0].id) : null;
|
|
762
|
+
const bw =
|
|
763
|
+
cfg.adaptiveBm === false
|
|
764
|
+
? cfg.bmWeight
|
|
765
|
+
: adaptiveBmWeight(q, bm25Hits[0]?.id, topTool?.description);
|
|
766
|
+
fused = rrfFuse(bm25Hits, semHits.slice(0, lim * 2), lim, {
|
|
767
|
+
semWeight: cfg.semanticWeight,
|
|
768
|
+
bmWeight: bw,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
} catch (e) {
|
|
772
|
+
logger.warn("semantic leg failed (%s) — BM25 only", e?.message);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// v0.3.0 正文精排(借鉴 SkillRouter 两阶段检索):在已召回候选集内用
|
|
777
|
+
// 完整描述 + 参数 schema 重排,**不扩大候选**(技能侧实测扩大候选会
|
|
778
|
+
// 让泛化词把无关项顶上来)。候选之外的 BM25/语义命中保持原序接在后面。
|
|
779
|
+
if (cfg.bodyRerank && fused.length > 1) {
|
|
780
|
+
try {
|
|
781
|
+
const topN = Math.min(cfg.bodyRerankTopN, fused.length);
|
|
782
|
+
const head = fused.slice(0, topN);
|
|
783
|
+
const tail = fused.slice(topN);
|
|
784
|
+
const reranked = rerankByBody(q, head, byName, { weight: cfg.bodyRerankWeight });
|
|
785
|
+
fused = [...reranked.map((r) => ({ id: r.id })), ...tail];
|
|
786
|
+
} catch (e) {
|
|
787
|
+
logger.warn("body rerank failed (%s) — keep fused order", e?.message);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 精确名/名字串命中永远钉在最前(正文重排也不许把它挤下去)。
|
|
792
|
+
const exact = bm25Hits.filter((h) => {
|
|
793
|
+
const name = String(h.id || "").toLowerCase();
|
|
794
|
+
const needle = q.trim().toLowerCase();
|
|
795
|
+
return needle && (name === needle || name.endsWith(needle) || name.includes(needle));
|
|
796
|
+
});
|
|
797
|
+
if (exact.length) {
|
|
798
|
+
const pinned = new Set(exact.map((h) => h.id));
|
|
799
|
+
fused = [...exact, ...fused.filter((h) => !pinned.has(h.id))];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
let results = fused.map((h) => {
|
|
803
|
+
const t = byName.get(h.id) || {};
|
|
804
|
+
return {
|
|
805
|
+
name: h.id,
|
|
806
|
+
description: (t.description || "").slice(0, 160),
|
|
807
|
+
server: serverOf(h.id),
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
// P0-①: 零匹配回退轻目录(name+80字描述,名字典序)——根治「搜词不对
|
|
811
|
+
// 就以为没有该能力」。fallbackCatalogSize=0 关闭(返回空=旧行为)。
|
|
812
|
+
// total 在回退时=全量目录大小(而非列出条数),避免模型误以为目录总容量=limit。
|
|
813
|
+
let fallback = false;
|
|
814
|
+
if (results.length === 0 && cfg.fallbackCatalogSize > 0) {
|
|
815
|
+
results = fallbackCatalog(allTools, cfg.fallbackCatalogSize, serverOf);
|
|
816
|
+
fallback = true;
|
|
817
|
+
}
|
|
818
|
+
// v0.2.2: 被搜到的工具名进入 warm 集合,下一轮注入完整 schema。
|
|
819
|
+
touchWarmNames(results.map((r) => r.name));
|
|
820
|
+
// P0-④: 发现率统计(节流落盘,低频工具名单反哺描述改进)。
|
|
821
|
+
recordDiscoveries(discoveries, results.map((r) => r.name));
|
|
822
|
+
persistDiscoveries();
|
|
823
|
+
const out = {
|
|
824
|
+
query: q,
|
|
825
|
+
results,
|
|
826
|
+
total: fallback ? allTools.length : results.length,
|
|
827
|
+
...(fallback ? { fallback: true } : {}),
|
|
828
|
+
};
|
|
829
|
+
if (q) searchCache.set(cacheKey, out);
|
|
830
|
+
return out;
|
|
831
|
+
} catch (e) {
|
|
832
|
+
logger.warn("tools_search failed (%s) — empty results", e?.message);
|
|
833
|
+
return { query: String(args.query || ""), results: [], total: 0 };
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
try {
|
|
838
|
+
ctx.tools.register(toolsSearch);
|
|
839
|
+
logger.info("tools_search registered");
|
|
840
|
+
} catch (e) {
|
|
841
|
+
logger.warn("tools_search register failed: %s", e?.message);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// L4: `tools_schema` — model-driven full-schema discovery for one tool.
|
|
846
|
+
// Complementary to tools_search: search narrows candidates, this returns the
|
|
847
|
+
// exact parameter schema + full description of one named tool.
|
|
848
|
+
// RED LINE (C3): output MUST be `{ schema: { type: "json" } }` — the return
|
|
849
|
+
// value is an arbitrary tool schema, and a strict object schema with
|
|
850
|
+
// additionalProperties:false would fail the host's "declared property"
|
|
851
|
+
// validation (ToolOutputError). The `type:"json"` annotation schema only
|
|
852
|
+
// checks lossless JSON (C4).
|
|
853
|
+
if (cfg.schemaToolEnabled) {
|
|
854
|
+
const schemaTool = defineTool({
|
|
855
|
+
name: "tools_schema",
|
|
856
|
+
description:
|
|
857
|
+
"Get the complete parameter schema of one or more tools by name. Use after " +
|
|
858
|
+
"tools_search narrows candidates, or before calling a tool whose exact " +
|
|
859
|
+
"arguments you need. Returns the full JSON schema of parameters plus the " +
|
|
860
|
+
"full description; sibling tools from the same server are listed alongside " +
|
|
861
|
+
"(lightweight) so you can batch-expand what you need.",
|
|
862
|
+
parameters: {
|
|
863
|
+
name: {
|
|
864
|
+
type: "string",
|
|
865
|
+
description: "Exact tool name, e.g. 'mcp__viking__remember' (either name or names).",
|
|
866
|
+
},
|
|
867
|
+
names: {
|
|
868
|
+
type: "array",
|
|
869
|
+
items: { type: "string" },
|
|
870
|
+
description: "Batch: exact tool names to expand in one call (either name or names).",
|
|
871
|
+
},
|
|
872
|
+
closure: {
|
|
873
|
+
type: "boolean",
|
|
874
|
+
description: "Include lightweight sibling tools from the same server (default true).",
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
output: {
|
|
878
|
+
schema: { type: "json" },
|
|
879
|
+
render: (_a, v) => [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
880
|
+
},
|
|
881
|
+
isConcurrencySafe: () => true,
|
|
882
|
+
async execute(args) {
|
|
883
|
+
// P1: 批量展开(names 数组)+ 同 server 兄弟闭包(closureSize 条轻目录)。
|
|
884
|
+
// 输出契约全部在 buildSchemaResponse(纯函数,lib/ux.js 可单测)。
|
|
885
|
+
const res = buildSchemaResponse(allTools, args, { closureSize: cfg.closureSize, serverOf });
|
|
886
|
+
// v0.2.2: 展开过 schema 的工具名进入 warm 集合,下一轮保持注入。
|
|
887
|
+
touchWarmNames(
|
|
888
|
+
Array.isArray(res.results) ? res.results.map((r) => r.name) : res.found && res.name ? [res.name] : [],
|
|
889
|
+
);
|
|
890
|
+
return res;
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
try {
|
|
894
|
+
ctx.tools.register(schemaTool);
|
|
895
|
+
logger.info("tools_schema registered");
|
|
896
|
+
} catch (e) {
|
|
897
|
+
logger.warn("tools_schema register failed: %s", e?.message);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
disposers.push(
|
|
902
|
+
ctx.on("system-prompt/assemble", async (assembly, context, next) => {
|
|
903
|
+
const t0 = Date.now();
|
|
904
|
+
try {
|
|
905
|
+
const assembled = await next();
|
|
906
|
+
const assembledTools = assembled.tools || [];
|
|
907
|
+
if (!Array.isArray(assembledTools)) return assembled;
|
|
908
|
+
// P2-1 include whitelist + deny config remove tools from the injected
|
|
909
|
+
// surface BEFORE folding. Semantics: include non-empty → whitelist mode,
|
|
910
|
+
// only matching tools enter the pool (injection surface + catalog +
|
|
911
|
+
// tools_search/tools_schema search scope); execution is NOT gated here
|
|
912
|
+
// (C5 — pre-execute only checks deny + firewall). Deny still wins.
|
|
913
|
+
// Meta tools are exempt from include so a whitelist can never hide the
|
|
914
|
+
// discovery tools themselves (that would break the always-visible loop).
|
|
915
|
+
const isMetaTool = (n) => n === "tools_search" || n === "tools_schema";
|
|
916
|
+
// v0.3.0: 按名字去重。宿主的组装结果在极端情况下可能重复(同一工具被两个
|
|
917
|
+
// 插件注册),重复项会让 BM25/RRF 把同一 id 的分数算两次——实测能把正确项
|
|
918
|
+
// 从第一挤到第三以外。
|
|
919
|
+
const tools = [];
|
|
920
|
+
const seenToolNames = new Set();
|
|
921
|
+
for (const t of assembledTools) {
|
|
922
|
+
if (isDenied(t?.name)) continue;
|
|
923
|
+
if (!(isMetaTool(t?.name) || includeAll || matchesAnyPattern(t?.name, includePatterns))) continue;
|
|
924
|
+
if (seenToolNames.has(t.name)) continue;
|
|
925
|
+
seenToolNames.add(t.name);
|
|
926
|
+
tools.push(t);
|
|
927
|
+
}
|
|
928
|
+
if (tools.length === 0) return { ...assembled, tools };
|
|
929
|
+
|
|
930
|
+
const agentId = context?.agent?.id || "default";
|
|
931
|
+
const coreNames = new Set(cfg.perAgent?.[agentId]?.core || cfg.core || []);
|
|
932
|
+
// v0.2.1: hot = sliding-window decay (hotWindowDays). Tools not called
|
|
933
|
+
// inside the window lose their historical heat — the old code promoted
|
|
934
|
+
// forever, which slowly nullified the fold.
|
|
935
|
+
const hotNames = new Set(
|
|
936
|
+
Object.keys(feedback.calls || {}).filter((n) =>
|
|
937
|
+
isHotTool(feedback.calls, n, cfg.hotThreshold, cfg.hotWindowDays * 86400000)),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const stamp = tools.map((t) => t.name).join("\u0001");
|
|
941
|
+
if (indexStamp !== stamp) {
|
|
942
|
+
// V4 document expansion (measured 0.1.7→0.1.8): the tool corpus is
|
|
943
|
+
// pure-English, so Chinese queries always get df=0 and BM25 scores
|
|
944
|
+
// nothing (measured recall 0/26). Inject per-tool aliases from
|
|
945
|
+
// cfg.aliases (prefix-matched) into the index text — CN recall
|
|
946
|
+
// 0%→88%, EN +15pp, both from the same expansion.
|
|
947
|
+
index = buildIndex(
|
|
948
|
+
tools.map((t) => {
|
|
949
|
+
const aliasWords = [];
|
|
950
|
+
for (const [prefix, keywords] of Object.entries(cfg.aliases || {})) {
|
|
951
|
+
if (t.name.startsWith(prefix)) aliasWords.push(...keywords);
|
|
952
|
+
}
|
|
953
|
+
return {
|
|
954
|
+
id: t.name,
|
|
955
|
+
text: `${t.name} ${t.description || ""} ${aliasWords.join(" ")}`,
|
|
956
|
+
};
|
|
957
|
+
}),
|
|
958
|
+
);
|
|
959
|
+
indexStamp = stamp;
|
|
960
|
+
allTools = tools;
|
|
961
|
+
logger.info("index rebuilt: %d tools (doc-expansion enabled)", tools.length);
|
|
962
|
+
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const query = extractQuery(context);
|
|
966
|
+
// v0.3.0:语义索引在后台构建(首次冷启动),查询向量也后台预热。
|
|
967
|
+
// 两处都是 fire-and-forget:assemble 是热路径,绝不因为 embedding 变慢。
|
|
968
|
+
ensureSemanticIndex();
|
|
969
|
+
prefetchQuery(query);
|
|
970
|
+
const dynNames = buildDynamicSet(tools, query);
|
|
971
|
+
|
|
972
|
+
// v0.2.2: warm 集合名(只取当前工具池里存在的,避免引用已不存在的工具)。
|
|
973
|
+
const warmNamesSet = new Set(
|
|
974
|
+
[...warm.keys()].filter((n) => tools.some((t) => t.name === n)),
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
// L5: stable ordering — core by config order, hot by name, dyn by name.
|
|
978
|
+
// A deterministic byte order keeps the prompt prefix cacheable.
|
|
979
|
+
const coreTools = tools.filter((t) => coreNames.has(t.name));
|
|
980
|
+
const hotTools = tools
|
|
981
|
+
.filter((t) => !coreNames.has(t.name) && hotNames.has(t.name))
|
|
982
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
983
|
+
const dynTools = tools
|
|
984
|
+
.filter(
|
|
985
|
+
(t) =>
|
|
986
|
+
!coreNames.has(t.name) &&
|
|
987
|
+
!hotNames.has(t.name) &&
|
|
988
|
+
!warmNamesSet.has(t.name) &&
|
|
989
|
+
dynNames.has(t.name),
|
|
990
|
+
)
|
|
991
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
992
|
+
|
|
993
|
+
// v0.2.2: warm = 上一轮被 tools_search/schema 发现或折叠后被调用的工具。
|
|
994
|
+
// 属主动晋升(接近 hot),完整 schema 注入,不作为 budget 可降级段。
|
|
995
|
+
const warmTools = tools
|
|
996
|
+
.filter(
|
|
997
|
+
(t) =>
|
|
998
|
+
!coreNames.has(t.name) &&
|
|
999
|
+
!hotNames.has(t.name) &&
|
|
1000
|
+
warmNamesSet.has(t.name),
|
|
1001
|
+
)
|
|
1002
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
1003
|
+
|
|
1004
|
+
// L1: schema compression + description normalization on the dynamic
|
|
1005
|
+
// segment only. core/hot/warm never pass through this pipe (P0 red line).
|
|
1006
|
+
const trimmed =
|
|
1007
|
+
effectiveLevel !== "off" || cfg.normalizeDescriptions
|
|
1008
|
+
? dynTools.map((t) => {
|
|
1009
|
+
let out = t;
|
|
1010
|
+
if (cfg.normalizeDescriptions && typeof t.description === "string") {
|
|
1011
|
+
out = { ...out, description: normalizeDescription(t.description) };
|
|
1012
|
+
}
|
|
1013
|
+
return effectiveLevel !== "off"
|
|
1014
|
+
? compressTool(out, { level: effectiveLevel })
|
|
1015
|
+
: out;
|
|
1016
|
+
})
|
|
1017
|
+
: dynTools;
|
|
1018
|
+
|
|
1019
|
+
// v0.2.2 分级披露预算:动态段(trimmed)是唯一可降级段。core/hot/warm
|
|
1020
|
+
// 作为 protected 恒层级 1。tierForBudget 返回每个可见名字的降级层级;
|
|
1021
|
+
// 层级 2/3 组装压缩形态,层级 4 不再注入(由 catalog 兜底发现)。
|
|
1022
|
+
let visible = [...coreTools, ...hotTools, ...warmTools, ...trimmed];
|
|
1023
|
+
if (cfg.disclosureBudget > 0) {
|
|
1024
|
+
const protectedNames = new Set([
|
|
1025
|
+
...coreNames,
|
|
1026
|
+
...hotNames,
|
|
1027
|
+
...warmNamesSet,
|
|
1028
|
+
// meta 工具永远完整可见
|
|
1029
|
+
"tools_search",
|
|
1030
|
+
"tools_schema",
|
|
1031
|
+
]);
|
|
1032
|
+
const tiers = tierForBudget(visible, cfg.disclosureBudget, protectedNames);
|
|
1033
|
+
// Effective budget = min(disclosureBudget, context estimate). We do not
|
|
1034
|
+
// separately measure context here, so the hard disclosureBudget stands.
|
|
1035
|
+
// Rebuild visible honoring per-tool tiers (they are already ordered).
|
|
1036
|
+
visible = visible.map((t) => {
|
|
1037
|
+
const tier = tiers.byName[t.name] || 1;
|
|
1038
|
+
if (tier === 1) return t; // 完整 schema
|
|
1039
|
+
if (tier === 2) {
|
|
1040
|
+
// name + description(描述压缩至 100 字符)
|
|
1041
|
+
return {
|
|
1042
|
+
name: t.name,
|
|
1043
|
+
description: String(t.description || "").slice(0, 100),
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
if (tier === 3) {
|
|
1047
|
+
// 仅 name(一行一个)
|
|
1048
|
+
return { name: t.name };
|
|
1049
|
+
}
|
|
1050
|
+
return null; // 层级 4:折叠进 catalog,不注入
|
|
1051
|
+
}).filter(Boolean);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// The meta-tools must always stay visible or the model can never ask
|
|
1055
|
+
// for folded tools (they would be hidden by their own fold).
|
|
1056
|
+
const META_TOOLS = [
|
|
1057
|
+
{ name: "tools_search", enabled: cfg.toolSearchEnabled },
|
|
1058
|
+
{ name: "tools_schema", enabled: cfg.schemaToolEnabled },
|
|
1059
|
+
];
|
|
1060
|
+
for (const m of META_TOOLS) {
|
|
1061
|
+
if (m.enabled && !visible.some((t) => t.name === m.name)) {
|
|
1062
|
+
const meta = tools.find((t) => t.name === m.name);
|
|
1063
|
+
if (meta) visible.push(meta);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// v0.2.2 fail-open 硬保证(借鉴 fan56/dsh-mcp-adapter):启用的发现
|
|
1068
|
+
// 元工具仍缺失(注册失败/名冲突/部分启动)→ 折叠后工具不可发现,
|
|
1069
|
+
// 比不折叠更糟——显式回退官方全量工具列表,绝不静默吞掉发现路径。
|
|
1070
|
+
const missingMeta = missingMetaTools(
|
|
1071
|
+
new Set(visible.map((t) => t.name)),
|
|
1072
|
+
{ tools_search: cfg.toolSearchEnabled, tools_schema: cfg.schemaToolEnabled },
|
|
1073
|
+
);
|
|
1074
|
+
if (missingMeta.length > 0) {
|
|
1075
|
+
logger.warn(
|
|
1076
|
+
"fail-open: meta tool(s) %s missing after fold — falling back to full tool list",
|
|
1077
|
+
missingMeta.join(","),
|
|
1078
|
+
);
|
|
1079
|
+
return next();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const visibleNames = visible.map((t) => t.name);
|
|
1083
|
+
lastVisible = { agentId, names: visibleNames, ts: Date.now() };
|
|
1084
|
+
|
|
1085
|
+
let sections = assembled.sections;
|
|
1086
|
+
if (cfg.catalogEnabled) {
|
|
1087
|
+
const folded = tools.filter(
|
|
1088
|
+
(t) =>
|
|
1089
|
+
!isMetaTool(t.name) &&
|
|
1090
|
+
!coreNames.has(t.name) &&
|
|
1091
|
+
!hotNames.has(t.name) &&
|
|
1092
|
+
!warmNamesSet.has(t.name) &&
|
|
1093
|
+
!dynNames.has(t.name),
|
|
1094
|
+
);
|
|
1095
|
+
if (folded.length > 0) {
|
|
1096
|
+
sections = [...(assembled.sections || []), {
|
|
1097
|
+
name: "tool-folder-catalog",
|
|
1098
|
+
text: catalogText(folded),
|
|
1099
|
+
}];
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
logger.info(
|
|
1104
|
+
"inject=%d/%d agent=%s dyn=%d hot=%d q=%s",
|
|
1105
|
+
visible.length, tools.length, agentId, dynNames.size, hotNames.size,
|
|
1106
|
+
query ? query.slice(0, 40) : "(none)",
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
return { ...assembled, tools: visible, sections };
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
logger.warn("fold failed (%s) — falling back to full tool list", e?.message);
|
|
1112
|
+
return next();
|
|
1113
|
+
} finally {
|
|
1114
|
+
const dt = Date.now() - t0;
|
|
1115
|
+
if (dt > cfg.maxFoldMs) logger.warn("assemble fold took %dms (> %dms)", dt, cfg.maxFoldMs);
|
|
1116
|
+
}
|
|
1117
|
+
}),
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
// L3 + L4: heat feedback, coverage recording, and folded-call detection.
|
|
1121
|
+
// Bound to `tools/post-execute` — payload = (exec, result), exec.name /
|
|
1122
|
+
// exec.arguments are available. (`agent/pre-step` was the original hook but
|
|
1123
|
+
// its payload is { messages, ...position, signal } — no toolCalls — so the
|
|
1124
|
+
// feedback loop had no data. Verified against dsh-agent-loop preStep().)
|
|
1125
|
+
// This listener is deliberately side-effect only: it always returns next()
|
|
1126
|
+
// and never throws, so a feedback bug can never turn a tool result into
|
|
1127
|
+
// an isError (postExecute wraps a throwing listener as failure).
|
|
1128
|
+
disposers.push(
|
|
1129
|
+
ctx.on("tools/post-execute", (exec, _result, next) => {
|
|
1130
|
+
try {
|
|
1131
|
+
const name = exec?.name;
|
|
1132
|
+
if (!name) return next();
|
|
1133
|
+
|
|
1134
|
+
// P2-2 toonify (opt-in, default off): compact long JSON text blocks in
|
|
1135
|
+
// the result (drop empty fields + truncate long strings). Only fires
|
|
1136
|
+
// when the text block is >2000 chars AND parses as JSON; any failure
|
|
1137
|
+
// leaves the original block untouched — a compaction bug can never
|
|
1138
|
+
// corrupt a tool result.
|
|
1139
|
+
if (cfg.toonifyResults && _result && Array.isArray(_result.content)) {
|
|
1140
|
+
_result.content = _result.content.map((block) => {
|
|
1141
|
+
if (
|
|
1142
|
+
block?.type !== "text" ||
|
|
1143
|
+
typeof block.text !== "string" ||
|
|
1144
|
+
block.text.length <= 2000
|
|
1145
|
+
) {
|
|
1146
|
+
return block;
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
const v = JSON.parse(block.text);
|
|
1150
|
+
const compact = JSON.stringify(toonifyValue(v), null, 2);
|
|
1151
|
+
return compact.length < block.text.length ? { ...block, text: compact } : block;
|
|
1152
|
+
} catch {
|
|
1153
|
+
return block;
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Heat counting (L4 feedback). v0.2.1: upgrade to { n, ts } so the
|
|
1159
|
+
// hot gate can decay stale heat; legacy number entries are kept
|
|
1160
|
+
// readable by isHotTool (ts=0 → outside any positive window).
|
|
1161
|
+
const prev = feedback.calls[name];
|
|
1162
|
+
feedback.calls[name] = {
|
|
1163
|
+
n: (typeof prev === "number" ? prev : prev?.n || 0) + 1,
|
|
1164
|
+
ts: Date.now(),
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// Firewall: record the call sequence and flag exfil chains.
|
|
1168
|
+
if (cfg.firewallEnabled !== false) {
|
|
1169
|
+
chain.record(name, execArgumentsText(exec?.arguments));
|
|
1170
|
+
const exfil = chain.checkChain();
|
|
1171
|
+
if (exfil) {
|
|
1172
|
+
logger.warn(
|
|
1173
|
+
"[firewall] %s: %s",
|
|
1174
|
+
exfil.label, exfil.chain.join(" → "),
|
|
1175
|
+
);
|
|
1176
|
+
feedback.firewall = feedback.firewall || [];
|
|
1177
|
+
feedback.firewall.push({ ts: Date.now(), ...exfil });
|
|
1178
|
+
if (feedback.firewall.length > 20) {
|
|
1179
|
+
feedback.firewall.splice(0, feedback.firewall.length - 20);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Folded-call detection: did the model call something we hid?
|
|
1185
|
+
const cov = selectionCoverage(lastVisible.names, [name]);
|
|
1186
|
+
if (cov.uncovered.length > 0) {
|
|
1187
|
+
logger.warn(
|
|
1188
|
+
"folded tools called (coverage=%.2f): %s — promoting via heat",
|
|
1189
|
+
cov.coverage, cov.uncovered.join(", "),
|
|
1190
|
+
);
|
|
1191
|
+
// v0.2.2: 折叠后仍被调用的工具名也进 warm,下一轮避免再折叠。
|
|
1192
|
+
touchWarmNames(cov.uncovered);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// L3: record the last 50 sessions for offline evaluation.
|
|
1196
|
+
feedback.sessions.push({
|
|
1197
|
+
ts: Date.now(),
|
|
1198
|
+
agent: lastVisible.agentId,
|
|
1199
|
+
visible: lastVisible.names,
|
|
1200
|
+
calls: [name],
|
|
1201
|
+
coverage: cov.coverage,
|
|
1202
|
+
uncovered: cov.uncovered,
|
|
1203
|
+
});
|
|
1204
|
+
if (feedback.sessions.length > 50) feedback.sessions.splice(0, feedback.sessions.length - 50);
|
|
1205
|
+
|
|
1206
|
+
persistFeedback(fbPath, feedback, logger);
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
logger.warn("post-execute hook error (%s) — best-effort", e?.message);
|
|
1209
|
+
}
|
|
1210
|
+
return next();
|
|
1211
|
+
}),
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1214
|
+
// Firewall hard-block + deny config: refuse execution at tools/pre-execute.
|
|
1215
|
+
// Host contract (dsh-tools prepareExecution): the waterfall's gate is the
|
|
1216
|
+
// last value; return { kind: "deny", reason } to refuse, or next() to allow.
|
|
1217
|
+
// exec carries { callId, name, arguments, agent, signal } — no .deny().
|
|
1218
|
+
disposers.push(
|
|
1219
|
+
ctx.on("tools/pre-execute", (exec, next) => {
|
|
1220
|
+
try {
|
|
1221
|
+
const name = exec?.name;
|
|
1222
|
+
|
|
1223
|
+
// deny config: refuse execution regardless of firewallEnabled.
|
|
1224
|
+
if (isDenied(name)) {
|
|
1225
|
+
logger.warn("[deny] tool disabled by config: %s", name);
|
|
1226
|
+
return { kind: "deny", reason: `[tool-folder] 工具 ${name} 已被禁用(deny 配置)。` };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (cfg.firewallEnabled !== false) {
|
|
1230
|
+
// 2026-08-30 修复:高危硬拦只对「执行类工具」生效。
|
|
1231
|
+
// 原实现对所有工具的 arguments 跑高危正则,导致内容类工具
|
|
1232
|
+
// (write/edit/read 等参数是文件内容/文本)只要含危险命令的
|
|
1233
|
+
// 字面量(如讨论、测试、文档中的示例)就被误拦。
|
|
1234
|
+
// 执行类工具的参数才是真实要执行的命令,需要硬拦。
|
|
1235
|
+
if (isExecTool(name)) {
|
|
1236
|
+
const argvText = execArgumentsText(exec?.arguments);
|
|
1237
|
+
const v = verdict(argvText);
|
|
1238
|
+
if (v?.blocked) {
|
|
1239
|
+
logger.warn("[firewall] BLOCKED %s: %s", name, v.label);
|
|
1240
|
+
return { kind: "deny", reason: `[tool-folder firewall] 高危命令已拦截(${v.label})。如需执行请人工确认。` };
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
} catch (e) {
|
|
1245
|
+
logger.warn("pre-execute hook error (%s) — best-effort", e?.message);
|
|
1246
|
+
}
|
|
1247
|
+
return next();
|
|
1248
|
+
}),
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
// Cleanup on plugin unload: no leaked listeners.
|
|
1252
|
+
return () => {
|
|
1253
|
+
for (const d of disposers) {
|
|
1254
|
+
try {
|
|
1255
|
+
d();
|
|
1256
|
+
} catch {
|
|
1257
|
+
/* already disposed */
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
}
|