@anionex/dsh-tool-search 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-tool-search",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Codex-style deferred tool loading for DSH with deterministic local BM25 search",
5
5
  "keywords": [
6
6
  "deepseek",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "compatibility": {
86
- "dsh": "^0.1.1-rc.1 || ^0.1.2-alpha.1",
86
+ "dsh": "^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
87
87
  "profiles": [
88
88
  "web",
89
89
  "headless"
@@ -92,14 +92,14 @@
92
92
  },
93
93
  "peerDependencies": {
94
94
  "@deepseek-ai/cordis": "^4.0.1",
95
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
96
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
97
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
98
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
99
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
100
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
101
- "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
102
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1",
95
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
96
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
97
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
98
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
99
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
100
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
101
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
102
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.8 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
103
103
  "@deepseek-ai/schemastery": "^3.18.1",
104
104
  "react": "^18.2.0"
105
105
  },
@@ -10,6 +10,17 @@ const packageVersion = JSON.parse(await readFile(join(root, 'package.json'), 'ut
10
10
  const dsh = process.env.DSH_BIN ?? 'dsh'
11
11
  const keepTemp = process.argv.includes('--keep-temp')
12
12
  const timeoutMs = Number(process.env.DSH_TOOL_SEARCH_E2E_TIMEOUT_MS ?? 180_000)
13
+ const maxModelRequests = Number(process.env.DSH_TOOL_SEARCH_E2E_MAX_REQUESTS ?? 12)
14
+
15
+ /**
16
+ * PTC presentation is selected by name: DSH renders it as `code` up to 0.1.1
17
+ * and as `ptc` from 0.1.2. Override with DSH_TOOL_SEARCH_PRESENTATION_MODE.
18
+ */
19
+ function presentationModeFor(version) {
20
+ const override = process.env.DSH_TOOL_SEARCH_PRESENTATION_MODE
21
+ if (override !== undefined && override !== '') return override
22
+ return version.startsWith('0.1.0') || version.startsWith('0.1.1') ? 'code' : 'ptc'
23
+ }
13
24
 
14
25
  function run(command, args, options = {}) {
15
26
  return new Promise((resolve, reject) => {
@@ -129,7 +140,19 @@ async function startLlm(script = [
129
140
  return
130
141
  }
131
142
  requests.push(body)
132
- const step = script[index++]
143
+ const names = requestToolNames(body)
144
+ const last = body?.messages?.at(-1)
145
+ process.stderr.write(
146
+ `[e2e] model request #${requests.length} tools=[${names.join(',')}] `
147
+ + `last=${JSON.stringify(last?.role ?? 'none')}:${JSON.stringify(String(last?.content ?? '').slice(0, 80))}\n`,
148
+ )
149
+ if (requests.length > maxModelRequests) {
150
+ // A runaway loop must fail here instead of growing the recorded bodies.
151
+ response.writeHead(500, { 'content-type': 'application/json' })
152
+ response.end(JSON.stringify({ error: { message: `request-limit-${maxModelRequests}` } }))
153
+ return
154
+ }
155
+ const step = typeof script === 'function' ? script(body) : script[index++]
133
156
  if (step === undefined) {
134
157
  response.writeHead(500, { 'content-type': 'application/json' })
135
158
  response.end('{"error":{"message":"script-exhausted"}}')
@@ -191,6 +214,19 @@ function requestToolNames(body) {
191
214
  return body?.tools?.map(tool => tool?.function?.name).filter(name => typeof name === 'string') ?? []
192
215
  }
193
216
 
217
+ function messageText(body) {
218
+ return body?.messages
219
+ ?.filter(message => message?.role === 'system' || message?.role === 'developer')
220
+ .map(message => typeof message.content === 'string' ? message.content : JSON.stringify(message.content ?? ''))
221
+ .join('\n') ?? ''
222
+ }
223
+
224
+ function conversationText(body) {
225
+ return body?.messages
226
+ ?.map(message => typeof message.content === 'string' ? message.content : JSON.stringify(message.content ?? ''))
227
+ .join('\n') ?? ''
228
+ }
229
+
194
230
  function toolResultTexts(body) {
195
231
  return body?.messages
196
232
  ?.filter(message => message?.role === 'tool')
@@ -361,6 +397,112 @@ try {
361
397
  const returnFileResult = toolResultTexts(llm.requests[2]).at(-1) ?? ''
362
398
  assert(returnFileResult.includes('"sent":true'), 'dsh_im_return_file did not execute through the real Agent loop')
363
399
 
400
+ const presentationChooser = step => {
401
+ // Under PTC the model only reaches tools through run_code, so the scripted
402
+ // calls are programs that search first and dispatch the selection after.
403
+ const names = requestToolNames(step)
404
+ if (!names.includes('run_code')) return { kind: 'text', text: 'auxiliary call' }
405
+ const transcript = conversationText(step)
406
+ // Match compact and pretty-printed results alike; a literal match loops.
407
+ if (/"sent"\s*:\s*true/u.test(transcript)) return { kind: 'text', text: 'presentation e2e done' }
408
+ if (transcript.includes('dsh_im_return_file') && transcript.includes('tool_search')) {
409
+ return {
410
+ kind: 'tool',
411
+ name: 'run_code',
412
+ arguments: JSON.stringify({
413
+ code: "const sent = await tools.dsh_im_return_file({ path: '/tmp/tool-search-e2e.txt' })\nreturn sent",
414
+ description: 'Return the file through the selected tool',
415
+ }),
416
+ }
417
+ }
418
+ return {
419
+ kind: 'tool',
420
+ name: 'run_code',
421
+ arguments: JSON.stringify({
422
+ code: "const selected = await tools.tool_search({ query: 'dsh_im_return_file', limit: 1 })\nreturn selected",
423
+ description: 'Search deferred tools',
424
+ }),
425
+ }
426
+ }
427
+
428
+ /** Run the presentation pass in one mode, asserting the loader-level guarantees. */
429
+ const runPresentationPass = async mode => {
430
+ const presentationLlm = await startLlm(presentationChooser)
431
+ try {
432
+ const presentationResult = await runDsh([
433
+ '--profile', 'headless',
434
+ '--patch', patch,
435
+ 'Find and call the tool that returns a file through the conversation.',
436
+ ], {
437
+ cwd: workspaceDirectory,
438
+ env: {
439
+ DSH_HOME: home,
440
+ DSH_TOOLS_MODE: mode,
441
+ DSH_TELEMETRY_DISABLED: '1',
442
+ DSH_PERMISSION_MODE: 'danger-full-access',
443
+ DEEPSEEK_API_KEY: 'tool-search-e2e-key',
444
+ DEEPSEEK_BASE_URL: presentationLlm.baseUrl,
445
+ },
446
+ })
447
+ assert(
448
+ presentationResult.stdout.trim() === 'presentation e2e done',
449
+ `unexpected ${mode}-mode Agent output: ${presentationResult.stdout}`,
450
+ )
451
+ const agentRequests = presentationLlm.requests.filter(body => requestToolNames(body).includes('run_code'))
452
+ assert(agentRequests.length === 3, `expected 3 ${mode}-mode model calls, received ${agentRequests.length}`)
453
+ const first = agentRequests[0]
454
+ assert(
455
+ requestToolNames(first).join(',') === 'run_code',
456
+ `${mode} assembly exposes more than the reserved transport: ${requestToolNames(first).join(',')}`,
457
+ )
458
+ const firstText = messageText(first)
459
+ assert(firstText.includes('tool_search'), `${mode} SDK omits the searchable tool_search`)
460
+ assert(firstText.includes('bash'), `${mode} SDK omits the always-visible bash`)
461
+ assert(!firstText.includes('fixture_echo'), `${mode} SDK advertises deferred fixture_echo`)
462
+ assert(!firstText.includes('dsh_im_return_file'), `${mode} SDK advertises deferred dsh_im_return_file`)
463
+ const afterSelection = agentRequests[1]
464
+ const afterText = messageText(afterSelection)
465
+ assert(afterText.includes('dsh_im_return_file'), `${mode} SDK omits the tool selected by tool_search`)
466
+ assert(!afterText.includes('fixture_echo'), `${mode} SDK advertises unrelated fixture_echo after search`)
467
+ assert(
468
+ presentationLlm.requests.some(body => /"sent"\s*:\s*true/u.test(conversationText(body))),
469
+ `the selected tool did not dispatch through run_code under ${mode}`,
470
+ )
471
+ return {
472
+ mode,
473
+ modelCalls: agentRequests.length,
474
+ transportOnly: true,
475
+ sdkHasToolSearch: true,
476
+ sdkHidesDeferredBeforeSearch: true,
477
+ sdkAddsSelectionAfterSearch: true,
478
+ selectedToolDispatched: true,
479
+ }
480
+ } finally {
481
+ await presentationLlm.close()
482
+ }
483
+ }
484
+
485
+ // The presentation name for the PTC collapse is `code` up to DSH 0.1.1 and
486
+ // `ptc` from 0.1.2. The guess is validated against the host, and a rejected
487
+ // name is retried once with the other spelling.
488
+ const presentationModes = [presentationModeFor(dshVersion)]
489
+ presentationModes.push(presentationModes[0] === 'ptc' ? 'code' : 'ptc')
490
+ let presentation
491
+ let modeRejection
492
+ for (const [index, mode] of presentationModes.entries()) {
493
+ try {
494
+ presentation = await runPresentationPass(mode)
495
+ break
496
+ } catch (error) {
497
+ const text = String(error)
498
+ // The loader reports `expected "native" | "ptc" | "both" but got "code"`.
499
+ const rejectedName = /expected[\s\S]{0,80}but got\s+"[a-z]+"/u.test(text)
500
+ if (!rejectedName || index === presentationModes.length - 1) throw error
501
+ modeRejection = text.split('\n').find(line => line.includes('but got'))?.trim()
502
+ }
503
+ }
504
+
505
+
364
506
  process.stdout.write(`${JSON.stringify({
365
507
  ok: true,
366
508
  dsh: dshVersion,
@@ -370,6 +512,9 @@ try {
370
512
  initialToolCount: initialNames.length,
371
513
  selectedToolCount: searchedNames.length,
372
514
  selectedOnNextCall: searchedNames.includes('dsh_im_return_file'),
515
+ presentation,
516
+ modeRejection,
517
+ dshScript,
373
518
  package: `@anionex/dsh-tool-search@${packageVersion}`,
374
519
  temporaryHome: keepTemp ? home : undefined,
375
520
  }, null, 2)}\n`)
package/src/runtime.ts CHANGED
@@ -5,6 +5,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
5
5
  import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
6
6
  import {
7
7
  defineTool,
8
+ renderToolsSdk,
9
+ renderToolsSdkPy,
10
+ RUN_CODE_NAME,
8
11
  type ToolDefinition,
9
12
  type ToolExecution,
10
13
  type ToolExecutionResult,
@@ -16,6 +19,8 @@ import { ToolPolicyResolver, normalizeSettings } from './settings.ts'
16
19
  import {
17
20
  MAX_RESULT_LIMIT,
18
21
  TOOL_SEARCH_NAME,
22
+ TOOL_SDK_LANGUAGE_MARKERS,
23
+ TOOL_SDK_SECTION_NAME,
19
24
  type CatalogSnapshot,
20
25
  type ToolSearchSettings,
21
26
  } from './shared.ts'
@@ -24,6 +29,16 @@ const RESULT_SCHEMA_VERSION = 1
24
29
  const MAX_QUERY_LENGTH = 4096
25
30
  const MAX_DESCRIPTION_LENGTH = 240
26
31
 
32
+ /** One entry of the generated PTC SDK contract, as `renderToolsSdk` expects it. */
33
+ type SdkSchema = Parameters<typeof renderToolsSdk>[0][number]
34
+
35
+ /** The subset of a registered tool definition the SDK projection consumes. */
36
+ interface ToolDefinitionLike {
37
+ description?: string
38
+ parameters?: unknown
39
+ output?: { schema?: unknown }
40
+ }
41
+
27
42
  export interface ToolSearchMatch {
28
43
  name: string
29
44
  description: string
@@ -296,31 +311,123 @@ export class ToolSearchRuntime {
296
311
  return state
297
312
  }
298
313
 
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
314
+ /**
315
+ * The searchable registry view for one Agent: every tool it could reach,
316
+ * whether or not the current presentation shows it.
317
+ *
318
+ * The reserved PTC transport is excluded: it is never searchable, never
319
+ * constrained by policy, and never part of the generated SDK it carries.
320
+ * Reading the registry (rather than the assembled schemas) is what keeps the
321
+ * corpus complete under PTC presentation, where the assembly itself carries
322
+ * only `run_code`.
323
+ */
324
+ private refreshCatalog(state: AgentState): ToolCatalogEntry[] {
325
+ if (state.catalogGeneration !== this.generation) {
326
+ state.catalog = buildCatalog(
327
+ (this.ctx.tools.schemas(state.agent) as ToolSchemaLike[])
328
+ .filter(tool => tool.name !== RUN_CODE_NAME),
329
+ )
330
+ state.catalogGeneration = this.generation
331
+ }
332
+ return state.catalog
303
333
  }
304
334
 
335
+ /**
336
+ * Hide every deferred tool from one final assembly.
337
+ *
338
+ * Two surfaces carry tools and both must agree: the native schema list plus
339
+ * its `tool:<name>` guidance sections, and — under `ptc`/`both` — the
340
+ * generated `tools:sdk` section. The SDK text is regenerated with the same
341
+ * renderer `dsh-tools` uses, from the surviving tools alone, so the filtered
342
+ * assembly is byte-identical to the one the registry would have built for
343
+ * that smaller tool set.
344
+ */
305
345
  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
346
+ const catalog = this.refreshCatalog(state)
347
+ const known = new Set(catalog.map(tool => tool.name))
311
348
  const visible = new Set(
312
- assembly.tools
349
+ catalog
313
350
  .map(tool => tool.name)
314
351
  .filter(name => this.policy.classify(name, state.selected) === 'always'),
315
352
  )
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 => {
353
+ const hidden = new Set(catalog.map(tool => tool.name).filter(name => !visible.has(name)))
354
+ const tools = assembly.tools.filter(tool => !known.has(tool.name) || visible.has(tool.name))
355
+ let sdkRewritten = false
356
+ const sections = assembly.sections
357
+ .filter(section => {
321
358
  const name = toolSectionName(section.name)
322
359
  return name === undefined || !hidden.has(name)
323
- }),
360
+ })
361
+ .map(section => {
362
+ // An empty SDK section is an Agent-scoped opt-out of PTC presentation
363
+ // (a native override under a PTC deployment): there is nothing to filter.
364
+ if (section.name !== TOOL_SDK_SECTION_NAME || hidden.size === 0 || section.text.trim().length === 0) {
365
+ return section
366
+ }
367
+ const text = this.renderSdk(state, visible, section.text)
368
+ if (text === section.text) return section
369
+ sdkRewritten = true
370
+ return { ...section, text }
371
+ })
372
+ if (tools.length === assembly.tools.length && sections.length === assembly.sections.length && !sdkRewritten) {
373
+ return assembly
374
+ }
375
+ return { ...assembly, tools, sections }
376
+ }
377
+
378
+ /**
379
+ * Rebuild the PTC SDK section for the surviving tools.
380
+ *
381
+ * The flavor must be the one the session already programs against, so it
382
+ * comes from the mounted code runtime; a host that cannot answer for it
383
+ * falls back to the marker the existing section was rendered with.
384
+ */
385
+ private renderSdk(state: AgentState, visible: ReadonlySet<string>, original: string): string {
386
+ const render = this.resolveSdkRenderer(original)
387
+ const schemas: SdkSchema[] = []
388
+ for (const tool of state.catalog) {
389
+ if (!visible.has(tool.name)) continue
390
+ const definition = this.toolDefinition(tool.name, state.agent)
391
+ schemas.push({
392
+ name: tool.name,
393
+ description: definition?.description ?? tool.description,
394
+ parameters: cloneJson(definition?.parameters ?? tool.parameters) as SdkSchema['parameters'],
395
+ output: cloneJson(definition?.output?.schema ?? {}) as SdkSchema['output'],
396
+ })
324
397
  }
398
+ return render(schemas)
399
+ }
400
+
401
+ private resolveSdkRenderer(original: string): (schemas: SdkSchema[]) => string {
402
+ const runtime = this.codeRuntime()
403
+ if (runtime.language === 'typescript') return renderToolsSdk
404
+ if (runtime.language === 'python') return renderToolsSdkPy
405
+ if (original.includes(TOOL_SDK_LANGUAGE_MARKERS.python)) return renderToolsSdkPy
406
+ if (original.includes(TOOL_SDK_LANGUAGE_MARKERS.typescript)) return renderToolsSdk
407
+ throw new Error(
408
+ `${TOOL_SEARCH_NAME}: cannot filter the generated tool SDK for code runtime language `
409
+ + `${JSON.stringify(runtime.language ?? 'unknown')}`,
410
+ )
411
+ }
412
+
413
+ private codeRuntime(): { language?: unknown } {
414
+ const runtime = (this.ctx as unknown as { get?: (name: string) => unknown }).get?.('codeRuntime')
415
+ return typeof runtime === 'object' && runtime !== null ? runtime as { language?: unknown } : {}
416
+ }
417
+
418
+ private toolDefinition(name: string, agent: Agent): ToolDefinitionLike | undefined {
419
+ const registry = this.ctx.tools as unknown as { get?: (name: string, scope?: unknown) => unknown }
420
+ if (typeof registry.get !== 'function') return undefined
421
+ const definition = registry.get(name, agent)
422
+ return typeof definition === 'object' && definition !== null ? definition as ToolDefinitionLike : undefined
423
+ }
424
+ }
425
+
426
+ /** Lossless clone of a schema node; a value that cannot be cloned is used as-is. */
427
+ function cloneJson<T>(value: T): T {
428
+ try {
429
+ return structuredClone(value)
430
+ } catch {
431
+ return value
325
432
  }
326
433
  }
package/src/shared.ts CHANGED
@@ -6,6 +6,20 @@ export const TOOL_SEARCH_CATALOG_ROUTE = '/_dsh/tool-search/catalog'
6
6
  export const DEFAULT_RESULT_LIMIT = 5
7
7
  export const MAX_RESULT_LIMIT = 20
8
8
 
9
+ /** Prompt section carrying the generated PTC tool SDK (`ptc` and `both` modes). */
10
+ export const TOOL_SDK_SECTION_NAME = 'tools:sdk'
11
+
12
+ /**
13
+ * Markers identifying which renderer produced an SDK section we did not
14
+ * assemble ourselves. Used only when the code runtime cannot be read from the
15
+ * context, because the renderer must match the flavor the model was told to
16
+ * program against.
17
+ */
18
+ export const TOOL_SDK_LANGUAGE_MARKERS = Object.freeze({
19
+ typescript: 'interface ToolArgsMap',
20
+ python: 'class Tools(Protocol)',
21
+ } as const)
22
+
9
23
  /**
10
24
  * Stable bootstrap surface for current DSH releases. Unknown future first-party
11
25
  * names are deferred until this list or the user allowlist is updated because