@anionex/dsh-tool-search 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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +160 -0
- package/cordis.patch.yml +3 -0
- package/docs/benchmark-results.json +51 -0
- package/lib/bm25.js +83 -0
- package/lib/bm25.js.map +1 -0
- package/lib/catalog.js +53 -0
- package/lib/catalog.js.map +1 -0
- package/lib/client.js +388 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +24 -0
- package/lib/index.js.map +1 -0
- package/lib/runtime.js +286 -0
- package/lib/runtime.js.map +1 -0
- package/lib/settings.js +50 -0
- package/lib/settings.js.map +1 -0
- package/lib/shared.js +40 -0
- package/lib/shared.js.map +1 -0
- package/lib/types/bm25.d.ts +20 -0
- package/lib/types/bm25.d.ts.map +1 -0
- package/lib/types/catalog.d.ts +26 -0
- package/lib/types/catalog.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +65 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +37 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/settings.d.ts +16 -0
- package/lib/types/settings.d.ts.map +1 -0
- package/lib/types/shared.d.ts +31 -0
- package/lib/types/shared.d.ts.map +1 -0
- package/lib/types/web.d.ts +5 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/web.js +28 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/pnpm-workspace.yaml +11 -0
- package/scripts/benchmark.mjs +124 -0
- package/scripts/profile-e2e.mjs +366 -0
- package/scripts/validate.mjs +170 -0
- package/src/bm25.ts +104 -0
- package/src/catalog.ts +72 -0
- package/src/client/index.tsx +344 -0
- package/src/index.ts +32 -0
- package/src/runtime.ts +326 -0
- package/src/settings.ts +63 -0
- package/src/shared.ts +63 -0
- package/src/web.ts +34 -0
- package/src/wink-porter2-stemmer.d.ts +3 -0
- package/tests/bm25.spec.ts +75 -0
- package/tests/catalog.spec.ts +63 -0
- package/tests/client.spec.tsx +39 -0
- package/tests/execution-regression.spec.ts +200 -0
- package/tests/runtime.spec.ts +271 -0
- package/tests/settings.spec.ts +33 -0
- package/tsconfig.client.json +31 -0
- package/tsconfig.json +30 -0
- package/tsconfig.test.json +23 -0
- package/tsdown.config.mjs +37 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/** Agent-local selected sets and model-visible tool filtering. */
|
|
2
|
+
|
|
3
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
5
|
+
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
|
6
|
+
import {
|
|
7
|
+
defineTool,
|
|
8
|
+
type ToolDefinition,
|
|
9
|
+
type ToolExecution,
|
|
10
|
+
type ToolExecutionResult,
|
|
11
|
+
type ToolRunContext,
|
|
12
|
+
} from '@deepseek-ai/dsh-tools'
|
|
13
|
+
import { Bm25Index } from './bm25.ts'
|
|
14
|
+
import { buildCatalog, type ToolCatalogEntry, type ToolSchemaLike } from './catalog.ts'
|
|
15
|
+
import { ToolPolicyResolver, normalizeSettings } from './settings.ts'
|
|
16
|
+
import {
|
|
17
|
+
MAX_RESULT_LIMIT,
|
|
18
|
+
TOOL_SEARCH_NAME,
|
|
19
|
+
type CatalogSnapshot,
|
|
20
|
+
type ToolSearchSettings,
|
|
21
|
+
} from './shared.ts'
|
|
22
|
+
|
|
23
|
+
const RESULT_SCHEMA_VERSION = 1
|
|
24
|
+
const MAX_QUERY_LENGTH = 4096
|
|
25
|
+
const MAX_DESCRIPTION_LENGTH = 240
|
|
26
|
+
|
|
27
|
+
export interface ToolSearchMatch {
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
score: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToolSearchResult {
|
|
34
|
+
schemaVersion: 1
|
|
35
|
+
query: string
|
|
36
|
+
tools: ToolSearchMatch[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface AgentState {
|
|
40
|
+
agent: Agent
|
|
41
|
+
selected: Set<string>
|
|
42
|
+
catalog: ToolCatalogEntry[]
|
|
43
|
+
catalogGeneration: number
|
|
44
|
+
disposeAssembly: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compareNames(left: string, right: string): number {
|
|
48
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summarize(description: string): string {
|
|
52
|
+
const normalized = description.replace(/\s+/gu, ' ').trim()
|
|
53
|
+
if (normalized.length <= MAX_DESCRIPTION_LENGTH) return normalized
|
|
54
|
+
return `${normalized.slice(0, MAX_DESCRIPTION_LENGTH - 3)}...`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseSelectionResult(value: unknown): string[] {
|
|
58
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return []
|
|
59
|
+
const record = value as Record<string, unknown>
|
|
60
|
+
if (record['schemaVersion'] !== RESULT_SCHEMA_VERSION || !Array.isArray(record['tools'])) return []
|
|
61
|
+
const names: string[] = []
|
|
62
|
+
for (const candidate of record['tools']) {
|
|
63
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) continue
|
|
64
|
+
const name = (candidate as Record<string, unknown>)['name']
|
|
65
|
+
if (typeof name === 'string' && name.length > 0) names.push(name)
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sessionEvents(agent: Agent): readonly unknown[] {
|
|
71
|
+
const session = agent.session as unknown as {
|
|
72
|
+
events?: readonly unknown[]
|
|
73
|
+
snapshotEvents?: () => readonly unknown[]
|
|
74
|
+
}
|
|
75
|
+
if (typeof session.snapshotEvents === 'function') return session.snapshotEvents()
|
|
76
|
+
return Array.isArray(session.events) ? session.events : []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectedFromSession(agent: Agent): Set<string> {
|
|
80
|
+
const searchCalls = new Set<string>()
|
|
81
|
+
const selected = new Set<string>()
|
|
82
|
+
for (const rawEvent of sessionEvents(agent)) {
|
|
83
|
+
if (typeof rawEvent !== 'object' || rawEvent === null || Array.isArray(rawEvent)) continue
|
|
84
|
+
const event = rawEvent as { type?: unknown, data?: Record<string, unknown> }
|
|
85
|
+
if (event.type === 'tool/call' && event.data?.['name'] === TOOL_SEARCH_NAME) {
|
|
86
|
+
searchCalls.add(String(event.data['callId']))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (event.type !== 'tool/result' || typeof event.data?.['message'] !== 'object' || event.data['message'] === null) continue
|
|
90
|
+
const content = (event.data['message'] as { content?: unknown }).content
|
|
91
|
+
const result = Array.isArray(content) ? content[0] : undefined
|
|
92
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result)) continue
|
|
93
|
+
const resultRecord = result as Record<string, unknown>
|
|
94
|
+
if (!searchCalls.has(String(resultRecord['toolCallId'])) || resultRecord['isError'] === true) continue
|
|
95
|
+
const blocks = resultRecord['content']
|
|
96
|
+
if (!Array.isArray(blocks)) continue
|
|
97
|
+
const text = blocks
|
|
98
|
+
.filter((block): block is { type: 'text', text: string } => (
|
|
99
|
+
typeof block === 'object'
|
|
100
|
+
&& block !== null
|
|
101
|
+
&& !Array.isArray(block)
|
|
102
|
+
&& (block as Record<string, unknown>)['type'] === 'text'
|
|
103
|
+
&& typeof (block as Record<string, unknown>)['text'] === 'string'
|
|
104
|
+
))
|
|
105
|
+
.map(block => block.text)
|
|
106
|
+
.join('')
|
|
107
|
+
try {
|
|
108
|
+
for (const name of parseSelectionResult(JSON.parse(text))) selected.add(name)
|
|
109
|
+
} catch {
|
|
110
|
+
// Failed and legacy tool results are not successful selection transitions.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return selected
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toolSectionName(sectionName: string): string | undefined {
|
|
117
|
+
if (!sectionName.startsWith('tool:')) return undefined
|
|
118
|
+
const name = sectionName.slice('tool:'.length)
|
|
119
|
+
return name.length > 0 ? name : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class ToolSearchRuntime {
|
|
123
|
+
readonly definition: ToolDefinition
|
|
124
|
+
private readonly states = new Map<Agent, AgentState>()
|
|
125
|
+
private readonly policy: ToolPolicyResolver
|
|
126
|
+
private settings: ToolSearchSettings
|
|
127
|
+
private generation = 0
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
private readonly ctx: Context,
|
|
131
|
+
settings: ToolSearchSettings,
|
|
132
|
+
) {
|
|
133
|
+
this.settings = normalizeSettings(settings)
|
|
134
|
+
this.policy = new ToolPolicyResolver(this.settings)
|
|
135
|
+
this.definition = this.createDefinition()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
install(): () => void {
|
|
139
|
+
const disposers = [
|
|
140
|
+
this.ctx.on('agent/created', ({ agent }) => { this.attach(agent) }),
|
|
141
|
+
this.ctx.on('agent/disposed', ({ agent }) => { this.detach(agent) }),
|
|
142
|
+
this.ctx.on('tools/result', (exec, result) => { this.commitSelection(exec, result) }),
|
|
143
|
+
this.ctx.on('tools/change', () => { this.generation += 1 }),
|
|
144
|
+
this.ctx.on('system-prompt/change', () => { this.generation += 1 }),
|
|
145
|
+
]
|
|
146
|
+
for (const agent of this.ctx.agents.list()) this.attach(agent)
|
|
147
|
+
return () => {
|
|
148
|
+
for (const state of this.states.values()) state.disposeAssembly()
|
|
149
|
+
this.states.clear()
|
|
150
|
+
for (const dispose of disposers.reverse()) dispose()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
updateSettings(value: ToolSearchSettings): void {
|
|
155
|
+
this.settings = normalizeSettings(value)
|
|
156
|
+
this.policy.update(this.settings)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
snapshot(): CatalogSnapshot {
|
|
160
|
+
const tools = new Map<string, ToolCatalogEntry>()
|
|
161
|
+
for (const entry of buildCatalog(this.ctx.tools.schemas() as ToolSchemaLike[])) tools.set(entry.name, entry)
|
|
162
|
+
for (const state of this.states.values()) {
|
|
163
|
+
this.refreshCatalog(state)
|
|
164
|
+
for (const entry of state.catalog) tools.set(entry.name, entry)
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
schemaVersion: 1,
|
|
168
|
+
generation: this.generation,
|
|
169
|
+
tools: [...tools.values()].sort((left, right) => compareNames(left.name, right.name)).map(tool => ({
|
|
170
|
+
name: tool.name,
|
|
171
|
+
description: summarize(tool.description),
|
|
172
|
+
policy: this.policy.classify(tool.name),
|
|
173
|
+
selectedBy: [...this.states.values()]
|
|
174
|
+
.filter(state => state.selected.has(tool.name))
|
|
175
|
+
.map(state => String(state.agent.id))
|
|
176
|
+
.sort(compareNames),
|
|
177
|
+
})),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
selectedFor(agent: Agent): ReadonlySet<string> {
|
|
182
|
+
return this.requireState(agent).selected
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private createDefinition(): ToolDefinition {
|
|
186
|
+
return defineTool({
|
|
187
|
+
name: TOOL_SEARCH_NAME,
|
|
188
|
+
description: 'Search deferred tool metadata with deterministic local BM25. Matching tools become available by their real names on the next model call. Results contain summaries, not full schemas.',
|
|
189
|
+
parameters: {
|
|
190
|
+
query: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
required: true,
|
|
193
|
+
description: 'Search query for deferred tools.',
|
|
194
|
+
},
|
|
195
|
+
limit: {
|
|
196
|
+
type: 'integer',
|
|
197
|
+
description: 'Maximum number of tools to return. Defaults to 5.',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
output: {
|
|
201
|
+
schema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
additionalProperties: false,
|
|
204
|
+
properties: {
|
|
205
|
+
schemaVersion: { type: 'integer', const: RESULT_SCHEMA_VERSION, required: true },
|
|
206
|
+
query: { type: 'string', required: true },
|
|
207
|
+
tools: {
|
|
208
|
+
type: 'array',
|
|
209
|
+
required: true,
|
|
210
|
+
items: {
|
|
211
|
+
type: 'object',
|
|
212
|
+
additionalProperties: false,
|
|
213
|
+
properties: {
|
|
214
|
+
name: { type: 'string', required: true },
|
|
215
|
+
description: { type: 'string', required: true },
|
|
216
|
+
score: { type: 'number', required: true },
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
223
|
+
},
|
|
224
|
+
execute: (args, exec) => Promise.resolve(this.search(args, exec)),
|
|
225
|
+
finalizeContent: (_exec, result) => result.isError
|
|
226
|
+
? undefined
|
|
227
|
+
: [{ type: 'text', text: JSON.stringify(result.value) }],
|
|
228
|
+
presentCall: args => ({
|
|
229
|
+
card: 'generic',
|
|
230
|
+
title: 'Search deferred tools',
|
|
231
|
+
kind: 'read',
|
|
232
|
+
rawInput: typeof args === 'object' && args !== null && 'query' in args
|
|
233
|
+
? String((args as { query: unknown }).query)
|
|
234
|
+
: undefined,
|
|
235
|
+
}),
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private search(args: { query: string; limit?: number }, exec: ToolRunContext): ToolSearchResult {
|
|
240
|
+
const agent = exec.agent
|
|
241
|
+
if (agent === undefined) throw new Error(`${TOOL_SEARCH_NAME}: an Agent Session is required`)
|
|
242
|
+
const query = args.query.trim()
|
|
243
|
+
if (query.length === 0) throw new Error(`${TOOL_SEARCH_NAME}: query must not be empty`)
|
|
244
|
+
if (query.length > MAX_QUERY_LENGTH) throw new Error(`${TOOL_SEARCH_NAME}: query exceeds ${MAX_QUERY_LENGTH} characters`)
|
|
245
|
+
if (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1 || args.limit > MAX_RESULT_LIMIT)) {
|
|
246
|
+
throw new Error(`${TOOL_SEARCH_NAME}: limit must be an integer between 1 and ${MAX_RESULT_LIMIT}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const state = this.requireState(agent)
|
|
250
|
+
this.refreshCatalog(state)
|
|
251
|
+
const deferred = state.catalog.filter(tool => this.policy.classify(tool.name, state.selected) === 'deferred')
|
|
252
|
+
const index = new Bm25Index(deferred.map(tool => ({ id: tool, name: tool.name, text: tool.searchText })))
|
|
253
|
+
const limit = args.limit ?? this.settings.defaultLimit
|
|
254
|
+
const tools = index.search(query, limit).map(match => ({
|
|
255
|
+
name: match.id.name,
|
|
256
|
+
description: summarize(match.id.description),
|
|
257
|
+
score: Number(match.score.toFixed(6)),
|
|
258
|
+
}))
|
|
259
|
+
return { schemaVersion: RESULT_SCHEMA_VERSION, query, tools }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private commitSelection(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): void {
|
|
263
|
+
if (exec.name !== TOOL_SEARCH_NAME || exec.agent === undefined || result.isError) return
|
|
264
|
+
const state = this.requireState(exec.agent)
|
|
265
|
+
for (const name of parseSelectionResult(result.value)) state.selected.add(name)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private attach(agent: Agent): void {
|
|
269
|
+
if (this.states.has(agent)) return
|
|
270
|
+
const selected = selectedFromSession(agent)
|
|
271
|
+
const state = {
|
|
272
|
+
agent,
|
|
273
|
+
selected,
|
|
274
|
+
catalog: [],
|
|
275
|
+
catalogGeneration: -1,
|
|
276
|
+
disposeAssembly: () => {},
|
|
277
|
+
}
|
|
278
|
+
state.disposeAssembly = agent.ctx.on(
|
|
279
|
+
'system-prompt/assemble',
|
|
280
|
+
async (_assembly, _context, next) => this.filterAssembly(state, await next()),
|
|
281
|
+
{ prepend: true },
|
|
282
|
+
)
|
|
283
|
+
this.states.set(agent, state)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private detach(agent: Agent): void {
|
|
287
|
+
const state = this.states.get(agent)
|
|
288
|
+
if (state === undefined) return
|
|
289
|
+
this.states.delete(agent)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private requireState(agent: Agent): AgentState {
|
|
293
|
+
this.attach(agent)
|
|
294
|
+
const state = this.states.get(agent)
|
|
295
|
+
if (state === undefined) throw new Error(`${TOOL_SEARCH_NAME}: Agent state could not be initialized`)
|
|
296
|
+
return state
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private refreshCatalog(state: AgentState): void {
|
|
300
|
+
if (state.catalogGeneration === this.generation) return
|
|
301
|
+
state.catalog = buildCatalog(this.ctx.tools.schemas(state.agent) as ToolSchemaLike[])
|
|
302
|
+
state.catalogGeneration = this.generation
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private filterAssembly(state: AgentState, assembly: PromptAssembly): PromptAssembly {
|
|
306
|
+
if (assembly.sections.some(section => section.name === 'tools:sdk' && section.text.trim().length > 0)) {
|
|
307
|
+
throw new Error('dsh-tool-search requires native tool presentation; code/PTC and both modes are unsupported')
|
|
308
|
+
}
|
|
309
|
+
state.catalog = buildCatalog(assembly.tools as ToolSchemaLike[])
|
|
310
|
+
state.catalogGeneration = this.generation
|
|
311
|
+
const visible = new Set(
|
|
312
|
+
assembly.tools
|
|
313
|
+
.map(tool => tool.name)
|
|
314
|
+
.filter(name => this.policy.classify(name, state.selected) === 'always'),
|
|
315
|
+
)
|
|
316
|
+
const hidden = new Set(assembly.tools.map(tool => tool.name).filter(name => !visible.has(name)))
|
|
317
|
+
return {
|
|
318
|
+
...assembly,
|
|
319
|
+
tools: assembly.tools.filter(tool => visible.has(tool.name)),
|
|
320
|
+
sections: assembly.sections.filter(section => {
|
|
321
|
+
const name = toolSectionName(section.name)
|
|
322
|
+
return name === undefined || !hidden.has(name)
|
|
323
|
+
}),
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Validated Host settings and policy resolution. */
|
|
2
|
+
|
|
3
|
+
import z from '@deepseek-ai/schemastery'
|
|
4
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_CORE_TOOLS,
|
|
7
|
+
DEFAULT_RESULT_LIMIT,
|
|
8
|
+
MAX_RESULT_LIMIT,
|
|
9
|
+
TOOL_SEARCH_NAME,
|
|
10
|
+
TOOL_SEARCH_SETTINGS_NAMESPACE,
|
|
11
|
+
type ToolPolicy,
|
|
12
|
+
type ToolSearchSettings,
|
|
13
|
+
} from './shared.ts'
|
|
14
|
+
|
|
15
|
+
export const TOOL_SEARCH_SETTINGS_NS = TOOL_SEARCH_SETTINGS_NAMESPACE as SettingsNamespace
|
|
16
|
+
|
|
17
|
+
export const Config: z<ToolSearchSettings> = z.object({
|
|
18
|
+
defaultLimit: z.number().step(1).min(1).max(MAX_RESULT_LIMIT).default(DEFAULT_RESULT_LIMIT),
|
|
19
|
+
alwaysVisible: z.array(z.string()).default([]),
|
|
20
|
+
neverSearch: z.array(z.string()).default([]),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function normalizeNames(names: readonly string[]): string[] {
|
|
24
|
+
const normalized = new Set<string>()
|
|
25
|
+
for (const value of names) {
|
|
26
|
+
const name = value.trim()
|
|
27
|
+
if (name.length > 0) normalized.add(name)
|
|
28
|
+
}
|
|
29
|
+
return [...normalized].sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizeSettings(value: ToolSearchSettings): ToolSearchSettings {
|
|
33
|
+
const defaultLimit = Number.isSafeInteger(value.defaultLimit)
|
|
34
|
+
? Math.min(MAX_RESULT_LIMIT, Math.max(1, value.defaultLimit))
|
|
35
|
+
: DEFAULT_RESULT_LIMIT
|
|
36
|
+
return {
|
|
37
|
+
defaultLimit,
|
|
38
|
+
alwaysVisible: normalizeNames(value.alwaysVisible),
|
|
39
|
+
neverSearch: normalizeNames(value.neverSearch).filter(name => name !== TOOL_SEARCH_NAME),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ToolPolicyResolver {
|
|
44
|
+
private readonly core = new Set<string>(DEFAULT_CORE_TOOLS)
|
|
45
|
+
private always = new Set<string>()
|
|
46
|
+
private blocked = new Set<string>()
|
|
47
|
+
|
|
48
|
+
constructor(settings: ToolSearchSettings) {
|
|
49
|
+
this.update(settings)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
update(settings: ToolSearchSettings): void {
|
|
53
|
+
this.always = new Set(settings.alwaysVisible)
|
|
54
|
+
this.blocked = new Set(settings.neverSearch)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
classify(name: string, selected?: ReadonlySet<string>): ToolPolicy {
|
|
58
|
+
if (name === TOOL_SEARCH_NAME) return 'always'
|
|
59
|
+
if (this.blocked.has(name)) return 'blocked'
|
|
60
|
+
if (this.core.has(name) || this.always.has(name) || selected?.has(name) === true) return 'always'
|
|
61
|
+
return 'deferred'
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Values shared by the Host runtime and Web settings client. */
|
|
2
|
+
|
|
3
|
+
export const TOOL_SEARCH_NAME = 'tool_search'
|
|
4
|
+
export const TOOL_SEARCH_SETTINGS_NAMESPACE = 'tool-search'
|
|
5
|
+
export const TOOL_SEARCH_CATALOG_ROUTE = '/_dsh/tool-search/catalog'
|
|
6
|
+
export const DEFAULT_RESULT_LIMIT = 5
|
|
7
|
+
export const MAX_RESULT_LIMIT = 20
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Stable bootstrap surface for current DSH releases. Unknown future first-party
|
|
11
|
+
* names are deferred until this list or the user allowlist is updated because
|
|
12
|
+
* ToolDefinition does not expose package provenance.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_CORE_TOOLS = Object.freeze([
|
|
15
|
+
'apply_patch',
|
|
16
|
+
'ask_user_question',
|
|
17
|
+
'bash',
|
|
18
|
+
'create_goal',
|
|
19
|
+
'dsh_im_return_file',
|
|
20
|
+
'exit_plan_mode',
|
|
21
|
+
'get_goal',
|
|
22
|
+
'interrupt_agent',
|
|
23
|
+
'job_kill',
|
|
24
|
+
'job_list',
|
|
25
|
+
'job_output',
|
|
26
|
+
'list_agents',
|
|
27
|
+
'ralph',
|
|
28
|
+
'report',
|
|
29
|
+
'send_message',
|
|
30
|
+
'skill',
|
|
31
|
+
'subagent',
|
|
32
|
+
'subagent_fork',
|
|
33
|
+
'todo_write',
|
|
34
|
+
'update_goal',
|
|
35
|
+
'workflow',
|
|
36
|
+
] as const)
|
|
37
|
+
|
|
38
|
+
export interface ToolSearchSettings {
|
|
39
|
+
defaultLimit: number
|
|
40
|
+
alwaysVisible: string[]
|
|
41
|
+
neverSearch: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_SETTINGS: Readonly<ToolSearchSettings> = Object.freeze({
|
|
45
|
+
defaultLimit: DEFAULT_RESULT_LIMIT,
|
|
46
|
+
alwaysVisible: [],
|
|
47
|
+
neverSearch: [],
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type ToolPolicy = 'always' | 'deferred' | 'blocked'
|
|
51
|
+
|
|
52
|
+
export interface CatalogToolSummary {
|
|
53
|
+
name: string
|
|
54
|
+
description: string
|
|
55
|
+
policy: ToolPolicy
|
|
56
|
+
selectedBy: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CatalogSnapshot {
|
|
60
|
+
schemaVersion: 1
|
|
61
|
+
generation: number
|
|
62
|
+
tools: CatalogToolSummary[]
|
|
63
|
+
}
|
package/src/web.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Read-only live catalog endpoint for the Web settings page. */
|
|
2
|
+
|
|
3
|
+
import type { ServerResponse } from 'node:http'
|
|
4
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
6
|
+
import { TOOL_SEARCH_CATALOG_ROUTE } from './shared.ts'
|
|
7
|
+
import type { ToolSearchRuntime } from './runtime.ts'
|
|
8
|
+
|
|
9
|
+
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
|
10
|
+
const body = Buffer.from(JSON.stringify(value))
|
|
11
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
12
|
+
res.setHeader('Content-Length', String(body.length))
|
|
13
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
14
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
15
|
+
res.writeHead(status)
|
|
16
|
+
res.end(body)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function installCatalogRoute(ctx: Context, runtime: ToolSearchRuntime): void {
|
|
20
|
+
ctx.inject(['webServer'], webCtx => {
|
|
21
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
22
|
+
kind: 'exact',
|
|
23
|
+
path: TOOL_SEARCH_CATALOG_ROUTE,
|
|
24
|
+
handler: (req, res) => {
|
|
25
|
+
if (req.method !== 'GET') {
|
|
26
|
+
res.setHeader('Allow', 'GET')
|
|
27
|
+
sendJson(res, 405, { error: 'method-not-allowed' })
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
sendJson(res, 200, runtime.snapshot())
|
|
31
|
+
},
|
|
32
|
+
}), 'dsh-tool-search: Web catalog route')
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { Bm25Index, tokenize } from '../src/bm25.ts'
|
|
3
|
+
import { toolSearchText, type ToolSchemaLike } from '../src/catalog.ts'
|
|
4
|
+
|
|
5
|
+
const tools: ToolSchemaLike[] = [
|
|
6
|
+
{
|
|
7
|
+
name: 'browser_take_screenshot',
|
|
8
|
+
description: 'Capture a screenshot of a web page or selected element.',
|
|
9
|
+
parameters: { properties: { target: { description: 'Element to capture.' } } },
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'web_search',
|
|
13
|
+
description: 'Search the public web for current information and sources.',
|
|
14
|
+
parameters: { properties: { queries: { description: 'Search queries.' } } },
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: 'read_image',
|
|
18
|
+
description: 'Inspect a local PNG JPEG WebP or GIF image.',
|
|
19
|
+
parameters: { properties: { file_path: { description: 'Image path.' } } },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'browser_file_upload',
|
|
23
|
+
description: 'Upload one or more local files through a browser file chooser.',
|
|
24
|
+
parameters: { properties: { paths: { description: 'Absolute file paths.' } } },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'browser_network_requests',
|
|
28
|
+
description: 'List HTTP network requests made by the current browser page.',
|
|
29
|
+
parameters: { properties: { filter: { description: 'URL regular expression.' } } },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'dsh_im_return_file',
|
|
33
|
+
description: 'Send an existing local file to the user.',
|
|
34
|
+
parameters: { properties: { path: { description: 'File to deliver.' } } },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'browser_navigate',
|
|
38
|
+
description: 'Navigate the browser to a URL.',
|
|
39
|
+
parameters: { properties: { url: { description: 'Destination URL.' } } },
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
function index() {
|
|
44
|
+
return new Bm25Index(tools.map(tool => ({ id: tool.name, name: tool.name, text: toolSearchText(tool) })))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('deterministic BM25', () => {
|
|
48
|
+
it('guarantees exact-name Recall@1 even for a weak lexical score', () => {
|
|
49
|
+
expect(index().search('browser_network_requests', 1)[0]?.name).toBe('browser_network_requests')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it.each([
|
|
53
|
+
['take a screenshot of this page', 'browser_take_screenshot'],
|
|
54
|
+
['search online for recent sources', 'web_search'],
|
|
55
|
+
['inspect this png image', 'read_image'],
|
|
56
|
+
['upload a file in the web page', 'browser_file_upload'],
|
|
57
|
+
['send this local file to the user', 'dsh_im_return_file'],
|
|
58
|
+
])('places capability query %j within Top-5', (query, expected) => {
|
|
59
|
+
expect(index().search(query, 5).map(result => result.name)).toContain(expected)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('uses lexical tool name as the stable equal-score tie-break', () => {
|
|
63
|
+
const tied = new Bm25Index([
|
|
64
|
+
{ id: 2, name: 'zeta_tool', text: 'shared capability' },
|
|
65
|
+
{ id: 1, name: 'alpha_tool', text: 'shared capability' },
|
|
66
|
+
])
|
|
67
|
+
const first = tied.search('shared capability', 2)
|
|
68
|
+
for (let run = 0; run < 20; run += 1) expect(tied.search('shared capability', 2)).toEqual(first)
|
|
69
|
+
expect(first.map(result => result.name)).toEqual(['alpha_tool', 'zeta_tool'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('normalizes, removes English stop words, and stems terms', () => {
|
|
73
|
+
expect(tokenize('The uploaded files are RUNNING')).toEqual(['upload', 'file', 'run'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { appendSchemaSearchText, toolSearchText } from '../src/catalog.ts'
|
|
3
|
+
|
|
4
|
+
describe('Codex-aligned search documents', () => {
|
|
5
|
+
it('indexes the exact required fields and recursively nested descriptions', () => {
|
|
6
|
+
const text = toolSearchText({
|
|
7
|
+
name: 'browser_take_screenshot',
|
|
8
|
+
description: 'Capture the active browser page.',
|
|
9
|
+
parameters: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
description: 'Screenshot request.',
|
|
12
|
+
properties: {
|
|
13
|
+
target: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
description: 'Optional element target.',
|
|
16
|
+
properties: {
|
|
17
|
+
selector: { type: 'string', description: 'Unique CSS selector.' },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
formats: {
|
|
21
|
+
type: 'array',
|
|
22
|
+
description: 'Requested encodings.',
|
|
23
|
+
items: { type: 'string', description: 'One image encoding.' },
|
|
24
|
+
},
|
|
25
|
+
destination: {
|
|
26
|
+
anyOf: [
|
|
27
|
+
{ type: 'string', description: 'Output filename.' },
|
|
28
|
+
{
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
artifact: { type: 'boolean', description: 'Return an artifact.' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(text).toContain('browser_take_screenshot browser take screenshot')
|
|
41
|
+
expect(text).toContain('Capture the active browser page.')
|
|
42
|
+
expect(text).toContain('Screenshot request.')
|
|
43
|
+
expect(text).toContain('destination')
|
|
44
|
+
expect(text).toContain('formats Requested encodings. One image encoding.')
|
|
45
|
+
expect(text).toContain('target Optional element target. selector Unique CSS selector.')
|
|
46
|
+
expect(text).toContain('Output filename.')
|
|
47
|
+
expect(text).toContain('artifact Return an artifact.')
|
|
48
|
+
expect(text.indexOf('destination')).toBeLessThan(text.indexOf('formats'))
|
|
49
|
+
expect(text.indexOf('formats')).toBeLessThan(text.indexOf('target'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('ignores unsupported schema annotations and malformed nodes', () => {
|
|
53
|
+
const parts: string[] = []
|
|
54
|
+
appendSchemaSearchText({
|
|
55
|
+
title: 'Not indexed',
|
|
56
|
+
examples: ['Not indexed'],
|
|
57
|
+
properties: null,
|
|
58
|
+
items: 'not-a-schema',
|
|
59
|
+
anyOf: [{ description: 'Indexed branch' }, null],
|
|
60
|
+
}, parts)
|
|
61
|
+
expect(parts).toEqual(['Indexed branch'])
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ToolSearchSettingsSection } from '../src/client/index.tsx'
|
|
6
|
+
import type { ToolSearchSettings } from '../src/shared.ts'
|
|
7
|
+
|
|
8
|
+
describe('Tool Search settings section', () => {
|
|
9
|
+
it('renders the live catalog and persists policy changes', async () => {
|
|
10
|
+
const set = vi.fn(() => Promise.resolve())
|
|
11
|
+
const value: ToolSearchSettings = { defaultLimit: 5, alwaysVisible: [], neverSearch: [] }
|
|
12
|
+
const snapshot = { status: 'ready' as const, value, writable: true }
|
|
13
|
+
const scope = {
|
|
14
|
+
getSnapshot: () => snapshot,
|
|
15
|
+
subscribe: () => () => {},
|
|
16
|
+
set,
|
|
17
|
+
}
|
|
18
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
generation: 2,
|
|
21
|
+
tools: [{
|
|
22
|
+
name: 'browser_take_screenshot',
|
|
23
|
+
description: 'Capture a browser screenshot.',
|
|
24
|
+
policy: 'deferred',
|
|
25
|
+
selectedBy: [],
|
|
26
|
+
}],
|
|
27
|
+
}), { status: 200 }))))
|
|
28
|
+
|
|
29
|
+
render(<ToolSearchSettingsSection scope={scope} />)
|
|
30
|
+
expect(await screen.findByText('browser_take_screenshot')).toBeDefined()
|
|
31
|
+
expect(screen.getByLabelText('Live agents: 0').getAttribute('data-label')).toBe('Live agents')
|
|
32
|
+
fireEvent.change(screen.getByLabelText('Policy: browser_take_screenshot'), { target: { value: 'always' } })
|
|
33
|
+
await waitFor(() => {
|
|
34
|
+
expect(set).toHaveBeenCalledWith('alwaysVisible', ['browser_take_screenshot'])
|
|
35
|
+
expect(set).toHaveBeenCalledWith('neverSearch', [])
|
|
36
|
+
})
|
|
37
|
+
vi.unstubAllGlobals()
|
|
38
|
+
})
|
|
39
|
+
})
|