@anionex/dsh-tool-search 0.1.0 → 0.1.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.
@@ -0,0 +1,273 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent'
2
+ import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
3
+ import { renderToolsSdk, renderToolsSdkPy, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { ToolSearchRuntime, type ToolSearchResult } from '../src/runtime.ts'
6
+ import { TOOL_SEARCH_NAME, type ToolSearchSettings } from '../src/shared.ts'
7
+
8
+ type AssemblyListener = (
9
+ assembly: PromptAssembly,
10
+ context: object,
11
+ next: () => Promise<PromptAssembly>,
12
+ ) => Promise<PromptAssembly>
13
+
14
+ type RootListener = (...args: unknown[]) => unknown
15
+
16
+ interface FixtureTool {
17
+ name: string
18
+ description: string
19
+ parameters: Record<string, unknown>
20
+ output: Record<string, unknown>
21
+ }
22
+
23
+ function fixtureTool(name: string, description: string): FixtureTool {
24
+ return {
25
+ name,
26
+ description,
27
+ parameters: {
28
+ type: 'object',
29
+ additionalProperties: false,
30
+ properties: {
31
+ target: { type: 'string', description: `${description} target.` },
32
+ },
33
+ required: ['target'],
34
+ },
35
+ output: {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ properties: { ok: { type: 'boolean', required: true } },
39
+ },
40
+ }
41
+ }
42
+
43
+ const DEFAULT_TOOLS: FixtureTool[] = [
44
+ fixtureTool('apply_patch', 'Edit files with a patch'),
45
+ fixtureTool(TOOL_SEARCH_NAME, 'Search deferred tools'),
46
+ fixtureTool('browser_take_screenshot', 'Capture a browser screenshot'),
47
+ fixtureTool('web_search', 'Search current public web sources'),
48
+ ]
49
+
50
+ /** The `tools:sdk` body a real registry renders for one tool set. */
51
+ function sdkText(tools: readonly FixtureTool[], render = renderToolsSdk): string {
52
+ return render(tools.map(tool => ({
53
+ name: tool.name,
54
+ description: tool.description,
55
+ parameters: tool.parameters,
56
+ output: tool.output,
57
+ })))
58
+ }
59
+
60
+ function makeAgent(id: string) {
61
+ let assemblyListener: AssemblyListener | undefined
62
+ const agent = {
63
+ id,
64
+ session: { events: [] },
65
+ ctx: {
66
+ on(name: string, listener: AssemblyListener) {
67
+ if (name === 'system-prompt/assemble') assemblyListener = listener
68
+ return () => {
69
+ if (assemblyListener === listener) assemblyListener = undefined
70
+ }
71
+ },
72
+ },
73
+ } as unknown as Agent
74
+ return {
75
+ agent,
76
+ assemble(assembly: PromptAssembly): Promise<PromptAssembly> {
77
+ if (assemblyListener === undefined) throw new Error('assembly listener was not installed')
78
+ return assemblyListener(assembly, {}, () => Promise.resolve(assembly))
79
+ },
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Harness for one presentation mode. The registry view carries every tool plus
85
+ * the reserved transport; each mode then builds the assembly the real registry
86
+ * would build: `ptc` collapses the schema list to `run_code` and adds the
87
+ * generated SDK, `both` keeps both surfaces.
88
+ */
89
+ function harness(options: {
90
+ mode: 'native' | 'ptc' | 'both'
91
+ language?: string
92
+ settings?: Partial<ToolSearchSettings>
93
+ tools?: FixtureTool[]
94
+ withCodeRuntime?: boolean
95
+ } ) {
96
+ const tools = options.tools ?? DEFAULT_TOOLS
97
+ const agent = makeAgent('agent-1')
98
+ const listeners = new Map<string, RootListener[]>()
99
+ const ctx = {
100
+ agents: { list: () => [agent.agent] },
101
+ tools: {
102
+ // The registry view: every tool the Agent could reach. The reserved
103
+ // transport joins it only where PTC presentation is actually selected.
104
+ schemas: () => options.mode === 'native'
105
+ ? [...tools]
106
+ : [...tools, { name: RUN_CODE_NAME, description: 'Run a program', parameters: {} }],
107
+ // Registered definitions carry the output contract as `{ schema, render }`.
108
+ get: (name: string) => {
109
+ const tool = tools.find(candidate => candidate.name === name)
110
+ if (tool === undefined) return undefined
111
+ return { ...tool, output: { schema: tool.output, render: () => [] } }
112
+ },
113
+ },
114
+ get: (name: string) =>
115
+ name === 'codeRuntime' && options.withCodeRuntime !== false
116
+ ? { language: options.language ?? 'typescript' }
117
+ : undefined,
118
+ logger: { warn: vi.fn() },
119
+ on(name: string, listener: RootListener) {
120
+ const rows = listeners.get(name) ?? []
121
+ rows.push(listener)
122
+ listeners.set(name, rows)
123
+ return () => listeners.set(name, rows.filter(row => row !== listener))
124
+ },
125
+ }
126
+ const runtime = new ToolSearchRuntime(ctx as never, {
127
+ defaultLimit: 5,
128
+ alwaysVisible: [],
129
+ neverSearch: [],
130
+ ...options.settings,
131
+ })
132
+ const dispose = runtime.install()
133
+ const render = options.language === 'python' ? renderToolsSdkPy : renderToolsSdk
134
+ const assembly = (): PromptAssembly => {
135
+ const sections = tools.map(tool => ({ name: `tool:${tool.name}`, text: `${tool.name} guidance` }))
136
+ if (options.mode !== 'native') {
137
+ sections.unshift({ name: 'tools:ptc-only', text: `\`${RUN_CODE_NAME}\` is the only tool you can call directly.` })
138
+ sections.push({ name: 'tools:sdk', text: sdkText(tools, render) })
139
+ }
140
+ return {
141
+ sections,
142
+ contexts: [],
143
+ tools: (options.mode === 'native'
144
+ ? [...tools]
145
+ : options.mode === 'ptc'
146
+ ? [{ name: RUN_CODE_NAME, description: 'Run a program', parameters: {} }]
147
+ : [...tools, { name: RUN_CODE_NAME, description: 'Run a program', parameters: {} }]) as never,
148
+ variables: {},
149
+ }
150
+ }
151
+ return {
152
+ agent: agent.agent,
153
+ runtime,
154
+ dispose,
155
+ assemble: () => agent.assemble(assembly()),
156
+ assembleRaw: (value: PromptAssembly) => agent.assemble(value),
157
+ raw: assembly,
158
+ async search(query: string, limit?: number): Promise<ToolSearchResult> {
159
+ const exec = {
160
+ agent: agent.agent,
161
+ name: TOOL_SEARCH_NAME,
162
+ callId: 'call-search',
163
+ rootCallId: 'call-search',
164
+ token: Symbol('test-token'),
165
+ arguments: { query },
166
+ signal: new AbortController().signal,
167
+ }
168
+ const value = await runtime.definition.execute(
169
+ { query, ...(limit === undefined ? {} : { limit }) },
170
+ exec as never,
171
+ ) as ToolSearchResult
172
+ for (const listener of listeners.get('tools/result') ?? []) {
173
+ listener(exec, { isError: false, value, content: [{ type: 'text', text: JSON.stringify(value) }] })
174
+ }
175
+ return value
176
+ },
177
+ }
178
+ }
179
+
180
+ function sdkSection(assembly: PromptAssembly): string {
181
+ const section = assembly.sections.find(candidate => candidate.name === 'tools:sdk')
182
+ if (section === undefined) throw new Error('assembly carries no tools:sdk section')
183
+ return section.text
184
+ }
185
+
186
+ describe('PTC presentation support', () => {
187
+ it('filters the generated SDK and keeps the transport in ptc mode', async () => {
188
+ const app = harness({ mode: 'ptc' })
189
+ const assembly = await app.assemble()
190
+
191
+ expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME])
192
+ expect(sdkSection(assembly)).toBe(sdkText([
193
+ DEFAULT_TOOLS[0]!,
194
+ DEFAULT_TOOLS[1]!,
195
+ ]))
196
+ expect(sdkSection(assembly)).not.toContain('web_search')
197
+ expect(sdkSection(assembly)).not.toContain('browser_take_screenshot')
198
+ expect(sdkSection(assembly)).toContain('apply_patch')
199
+ expect(sdkSection(assembly)).toContain(TOOL_SEARCH_NAME)
200
+ expect(assembly.sections.map(section => section.name)).toContain('tools:ptc-only')
201
+ expect(assembly.sections.map(section => section.name)).not.toContain('tool:web_search')
202
+ app.dispose()
203
+ })
204
+
205
+ it('adds tools to the SDK on the next assembly after a successful search', async () => {
206
+ const app = harness({ mode: 'ptc' })
207
+ const before = await app.assemble()
208
+ expect(sdkSection(before)).not.toContain('web_search')
209
+
210
+ const result = await app.search('web search public sources', 1)
211
+ expect(result.tools.map(tool => tool.name)).toEqual(['web_search'])
212
+
213
+ const after = await app.assemble()
214
+ expect(sdkSection(after)).toContain('web_search')
215
+ expect(sdkSection(after)).toBe(sdkText([
216
+ DEFAULT_TOOLS[0]!,
217
+ DEFAULT_TOOLS[1]!,
218
+ DEFAULT_TOOLS[3]!,
219
+ ]))
220
+ expect(after.sections.map(section => section.name)).toContain('tool:web_search')
221
+ app.dispose()
222
+ })
223
+
224
+ it('filters both surfaces under the both presentation', async () => {
225
+ const app = harness({ mode: 'both' })
226
+ const assembly = await app.assemble()
227
+
228
+ expect(assembly.tools.map(tool => tool.name)).toEqual([
229
+ 'apply_patch',
230
+ TOOL_SEARCH_NAME,
231
+ RUN_CODE_NAME,
232
+ ])
233
+ expect(sdkSection(assembly)).toBe(sdkText([DEFAULT_TOOLS[0]!, DEFAULT_TOOLS[1]!]))
234
+ app.dispose()
235
+ })
236
+
237
+ it('renders the SDK flavor the mounted code runtime selects', async () => {
238
+ const app = harness({ mode: 'ptc', language: 'python' })
239
+ const assembly = await app.assemble()
240
+
241
+ expect(sdkSection(assembly)).toBe(sdkText([DEFAULT_TOOLS[0]!, DEFAULT_TOOLS[1]!], renderToolsSdkPy))
242
+ expect(sdkSection(assembly)).toContain('class Tools(Protocol)')
243
+ app.dispose()
244
+ })
245
+
246
+ it('reads the flavor from the section when the runtime is unreachable', async () => {
247
+ const app = harness({ mode: 'ptc', withCodeRuntime: false })
248
+ const assembly = await app.assemble()
249
+
250
+ expect(sdkSection(assembly)).toBe(sdkText([DEFAULT_TOOLS[0]!, DEFAULT_TOOLS[1]!]))
251
+ app.dispose()
252
+ })
253
+
254
+ it('leaves an Agent-scoped native override (empty SDK section) untouched', async () => {
255
+ const app = harness({ mode: 'native' })
256
+ const raw = app.raw()
257
+ raw.sections.push({ name: 'tools:sdk', text: '' })
258
+ const assembly = await app.assembleRaw(raw)
259
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['apply_patch', TOOL_SEARCH_NAME])
260
+ app.dispose()
261
+ })
262
+
263
+ it('returns the identical assembly when no tool is hidden', async () => {
264
+ const app = harness({
265
+ mode: 'ptc',
266
+ settings: { alwaysVisible: DEFAULT_TOOLS.map(tool => tool.name) },
267
+ })
268
+ const raw = app.raw()
269
+ const assembly = await app.assembleRaw(raw)
270
+ expect(assembly).toBe(raw)
271
+ app.dispose()
272
+ })
273
+ })
@@ -1,5 +1,6 @@
1
1
  import type { Agent } from '@deepseek-ai/dsh-agent'
2
2
  import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
3
+ import { renderToolsSdk } from '@deepseek-ai/dsh-tools'
3
4
  import { describe, expect, it, vi } from 'vitest'
4
5
  import type { ToolSchemaLike } from '../src/catalog.ts'
5
6
  import { ToolSearchRuntime, type ToolSearchResult } from '../src/runtime.ts'
@@ -251,11 +252,25 @@ describe('ToolSearchRuntime', () => {
251
252
  app.dispose()
252
253
  })
253
254
 
254
- it('rejects code and both presentation assemblies', async () => {
255
+ it('filters the generated SDK section instead of rejecting PTC and both assemblies', async () => {
255
256
  const app = harness()
256
257
  const assembly = app.assembly()
257
- assembly.sections.push({ name: 'tools:sdk', text: 'generated tool SDK' })
258
- await expect(app.agents[0]!.assemble(assembly)).rejects.toThrow(/requires native tool presentation/u)
258
+ assembly.sections.push({
259
+ name: 'tools:sdk',
260
+ text: renderToolsSdk(assembly.tools.map(tool => ({
261
+ name: tool.name,
262
+ description: tool.description ?? '',
263
+ parameters: tool.parameters,
264
+ output: {},
265
+ }))),
266
+ })
267
+ const filtered = await app.agents[0]!.assemble(assembly)
268
+ expect(filtered.tools.map(tool => tool.name)).toEqual(['apply_patch', TOOL_SEARCH_NAME])
269
+ const sdk = filtered.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
270
+ expect(sdk).toContain('apply_patch')
271
+ expect(sdk).toContain(TOOL_SEARCH_NAME)
272
+ expect(sdk).not.toContain('web_search')
273
+ expect(sdk).not.toContain('browser_take_screenshot')
259
274
  app.dispose()
260
275
  })
261
276
 
@@ -24,6 +24,7 @@ describe('tool exposure policy', () => {
24
24
  expect(policy.classify(TOOL_SEARCH_NAME)).toBe('always')
25
25
  expect(policy.classify('apply_patch')).toBe('always')
26
26
  expect(policy.classify('report')).toBe('always')
27
+ expect(policy.classify('dsh_im_return_file')).toBe('deferred')
27
28
  expect(policy.classify('bash')).toBe('blocked')
28
29
  expect(policy.classify('custom_tool')).toBe('always')
29
30
  expect(policy.classify('conflict_tool')).toBe('blocked')