@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.
@@ -31,7 +31,38 @@ export declare class ToolSearchRuntime {
31
31
  private attach;
32
32
  private detach;
33
33
  private requireState;
34
+ /**
35
+ * The searchable registry view for one Agent: every tool it could reach,
36
+ * whether or not the current presentation shows it.
37
+ *
38
+ * The reserved PTC transport is excluded: it is never searchable, never
39
+ * constrained by policy, and never part of the generated SDK it carries.
40
+ * Reading the registry (rather than the assembled schemas) is what keeps the
41
+ * corpus complete under PTC presentation, where the assembly itself carries
42
+ * only `run_code`.
43
+ */
34
44
  private refreshCatalog;
45
+ /**
46
+ * Hide every deferred tool from one final assembly.
47
+ *
48
+ * Two surfaces carry tools and both must agree: the native schema list plus
49
+ * its `tool:<name>` guidance sections, and — under `ptc`/`both` — the
50
+ * generated `tools:sdk` section. The SDK text is regenerated with the same
51
+ * renderer `dsh-tools` uses, from the surviving tools alone, so the filtered
52
+ * assembly is byte-identical to the one the registry would have built for
53
+ * that smaller tool set.
54
+ */
35
55
  private filterAssembly;
56
+ /**
57
+ * Rebuild the PTC SDK section for the surviving tools.
58
+ *
59
+ * The flavor must be the one the session already programs against, so it
60
+ * comes from the mounted code runtime; a host that cannot answer for it
61
+ * falls back to the marker the existing section was rendered with.
62
+ */
63
+ private renderSdk;
64
+ private resolveSdkRenderer;
65
+ private codeRuntime;
66
+ private toolDefinition;
36
67
  }
37
68
  //# sourceMappingURL=runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAElE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAEnD,OAAO,EAEL,KAAK,cAAc,EAIpB,MAAM,wBAAwB,CAAA;AAI/B,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAA;AAMpB,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,CAAC,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,eAAe,EAAE,CAAA;CACzB;AAqFD,qBAAa,iBAAiB;IAQ1B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA+B;IACtD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAC3C,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,UAAU,CAAI;gBAGH,GAAG,EAAE,OAAO,EAC7B,QAAQ,EAAE,kBAAkB;IAO9B,OAAO,IAAI,MAAM,IAAI;IAgBrB,cAAc,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAK/C,QAAQ,IAAI,eAAe;IAsB3B,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC;IAI9C,OAAO,CAAC,gBAAgB;IAsDxB,OAAO,CAAC,MAAM;IAuBd,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,MAAM;IAkBd,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,cAAc;IAMtB,OAAO,CAAC,cAAc;CAqBvB"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAElE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAEnD,OAAO,EAKL,KAAK,cAAc,EAIpB,MAAM,wBAAwB,CAAA;AAI/B,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACxB,MAAM,aAAa,CAAA;AAgBpB,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,CAAC,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,eAAe,EAAE,CAAA;CACzB;AAqFD,qBAAa,iBAAiB;IAQ1B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA+B;IACtD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoB;IAC3C,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,UAAU,CAAI;gBAGH,GAAG,EAAE,OAAO,EAC7B,QAAQ,EAAE,kBAAkB;IAO9B,OAAO,IAAI,MAAM,IAAI;IAgBrB,cAAc,CAAC,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAK/C,QAAQ,IAAI,eAAe;IAsB3B,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,WAAW,CAAC,MAAM,CAAC;IAI9C,OAAO,CAAC,gBAAgB;IAsDxB,OAAO,CAAC,MAAM;IAuBd,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,MAAM;IAkBd,OAAO,CAAC,MAAM;IAMd,OAAO,CAAC,YAAY;IAOpB;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;IAWtB;;;;;;;;;OASG;IACH,OAAO,CAAC,cAAc;IAiCtB;;;;;;OAMG;IACH,OAAO,CAAC,SAAS;IAgBjB,OAAO,CAAC,kBAAkB;IAY1B,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,cAAc;CAMvB"}
@@ -4,12 +4,24 @@ export declare const TOOL_SEARCH_SETTINGS_NAMESPACE = "tool-search";
4
4
  export declare const TOOL_SEARCH_CATALOG_ROUTE = "/_dsh/tool-search/catalog";
5
5
  export declare const DEFAULT_RESULT_LIMIT = 5;
6
6
  export declare const MAX_RESULT_LIMIT = 20;
7
+ /** Prompt section carrying the generated PTC tool SDK (`ptc` and `both` modes). */
8
+ export declare const TOOL_SDK_SECTION_NAME = "tools:sdk";
9
+ /**
10
+ * Markers identifying which renderer produced an SDK section we did not
11
+ * assemble ourselves. Used only when the code runtime cannot be read from the
12
+ * context, because the renderer must match the flavor the model was told to
13
+ * program against.
14
+ */
15
+ export declare const TOOL_SDK_LANGUAGE_MARKERS: Readonly<{
16
+ readonly typescript: "interface ToolArgsMap";
17
+ readonly python: "class Tools(Protocol)";
18
+ }>;
7
19
  /**
8
20
  * Stable bootstrap surface for current DSH releases. Unknown future first-party
9
21
  * names are deferred until this list or the user allowlist is updated because
10
22
  * ToolDefinition does not expose package provenance.
11
23
  */
12
- export declare const DEFAULT_CORE_TOOLS: readonly ["apply_patch", "ask_user_question", "bash", "create_goal", "dsh_im_return_file", "exit_plan_mode", "get_goal", "interrupt_agent", "job_kill", "job_list", "job_output", "list_agents", "ralph", "report", "send_message", "skill", "subagent", "subagent_fork", "todo_write", "update_goal", "workflow"];
24
+ export declare const DEFAULT_CORE_TOOLS: readonly ["apply_patch", "ask_user_question", "bash", "create_goal", "exit_plan_mode", "get_goal", "interrupt_agent", "job_kill", "job_list", "job_output", "list_agents", "ralph", "report", "send_message", "skill", "subagent", "subagent_fork", "todo_write", "update_goal", "workflow"];
13
25
  export interface ToolSearchSettings {
14
26
  defaultLimit: number;
15
27
  alwaysVisible: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/shared.ts"],"names":[],"mappings":"AAAA,iEAAiE;AAEjE,eAAO,MAAM,gBAAgB,gBAAgB,CAAA;AAC7C,eAAO,MAAM,8BAA8B,gBAAgB,CAAA;AAC3D,eAAO,MAAM,yBAAyB,8BAA8B,CAAA;AACpE,eAAO,MAAM,oBAAoB,IAAI,CAAA;AACrC,eAAO,MAAM,gBAAgB,KAAK,CAAA;AAElC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,oTAsBpB,CAAA;AAEX,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB;AAED,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAIxD,CAAA;AAEF,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAA;AAE1D,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,UAAU,CAAA;IAClB,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,CAAC,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,kBAAkB,EAAE,CAAA;CAC5B"}
1
+ {"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../../src/shared.ts"],"names":[],"mappings":"AAAA,iEAAiE;AAEjE,eAAO,MAAM,gBAAgB,gBAAgB,CAAA;AAC7C,eAAO,MAAM,8BAA8B,gBAAgB,CAAA;AAC3D,eAAO,MAAM,yBAAyB,8BAA8B,CAAA;AACpE,eAAO,MAAM,oBAAoB,IAAI,CAAA;AACrC,eAAO,MAAM,gBAAgB,KAAK,CAAA;AAElC,mFAAmF;AACnF,eAAO,MAAM,qBAAqB,cAAc,CAAA;AAEhD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB;;;EAG3B,CAAA;AAEX;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,8RAqBpB,CAAA;AAEX,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,MAAM,EAAE,CAAA;IACvB,WAAW,EAAE,MAAM,EAAE,CAAA;CACtB;AAED,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,kBAAkB,CAIxD,CAAA;AAEF,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAA;AAE1D,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,UAAU,CAAA;IAClB,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,CAAC,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,kBAAkB,EAAE,CAAA;CAC5B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-tool-search",
3
- "version": "0.1.0",
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
  },
@@ -1,14 +1,26 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { access, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
2
+ import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
3
3
  import { createServer } from 'node:http'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { dirname, join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
 
8
8
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
9
+ const packageVersion = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')).version
9
10
  const dsh = process.env.DSH_BIN ?? 'dsh'
10
11
  const keepTemp = process.argv.includes('--keep-temp')
11
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
+ }
12
24
 
13
25
  function run(command, args, options = {}) {
14
26
  return new Promise((resolve, reject) => {
@@ -88,14 +100,24 @@ async function fixturePackage(directory) {
88
100
  ' },',
89
101
  ' execute: args => Promise.resolve({ echo: args.value }),',
90
102
  ' }))',
103
+ ' ctx.tools.register(defineTool({',
104
+ " name: 'dsh_im_return_file',",
105
+ " description: 'Send a readable file to the user through the conversation.',",
106
+ " parameters: { path: { type: 'string', required: true, description: 'Readable file path.' } },",
107
+ ' output: {',
108
+ " schema: { type: 'object', additionalProperties: false, properties: { sent: { type: 'boolean', required: true } } },",
109
+ " render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],",
110
+ ' },',
111
+ ' execute: () => Promise.resolve({ sent: true }),',
112
+ ' }))',
91
113
  '}',
92
114
  '',
93
115
  ].join('\n'))
94
116
  }
95
117
 
96
118
  async function startLlm(script = [
97
- { kind: 'tool', name: 'tool_search', arguments: '{"query":"fixture_echo","limit":1}' },
98
- { kind: 'tool', name: 'fixture_echo', arguments: '{"value":"real-agent-ok"}' },
119
+ { kind: 'tool', name: 'tool_search', arguments: '{"query":"dsh_im_return_file","limit":1}' },
120
+ { kind: 'tool', name: 'dsh_im_return_file', arguments: '{"path":"/tmp/tool-search-e2e.txt"}' },
99
121
  { kind: 'text', text: 'tool search e2e done' },
100
122
  ]) {
101
123
  const requests = []
@@ -118,7 +140,19 @@ async function startLlm(script = [
118
140
  return
119
141
  }
120
142
  requests.push(body)
121
- 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++]
122
156
  if (step === undefined) {
123
157
  response.writeHead(500, { 'content-type': 'application/json' })
124
158
  response.end('{"error":{"message":"script-exhausted"}}')
@@ -180,6 +214,19 @@ function requestToolNames(body) {
180
214
  return body?.tools?.map(tool => tool?.function?.name).filter(name => typeof name === 'string') ?? []
181
215
  }
182
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
+
183
230
  function toolResultTexts(body) {
184
231
  return body?.messages
185
232
  ?.filter(message => message?.role === 'tool')
@@ -323,7 +370,7 @@ try {
323
370
  const runResult = await runDsh([
324
371
  '--profile', 'headless',
325
372
  '--patch', patch,
326
- 'Find and call the fixture echo tool.',
373
+ 'Find and call the tool that returns a file through the conversation.',
327
374
  ], {
328
375
  cwd: workspaceDirectory,
329
376
  env: {
@@ -341,12 +388,120 @@ try {
341
388
  const searchedNames = requestToolNames(llm.requests[1])
342
389
  assert(initialNames.includes('tool_search'), 'initial model call omits tool_search')
343
390
  assert(!initialNames.includes('fixture_echo'), 'initial model call exposes deferred fixture_echo')
344
- assert(searchedNames.includes('fixture_echo'), 'next model call omits selected fixture_echo')
391
+ assert(!initialNames.includes('dsh_im_return_file'), 'initial model call exposes plugin-provided dsh_im_return_file')
392
+ assert(!searchedNames.includes('fixture_echo'), 'searching dsh_im_return_file exposes unrelated fixture_echo')
393
+ assert(searchedNames.includes('dsh_im_return_file'), 'next model call omits selected dsh_im_return_file')
345
394
  const searchResult = toolResultTexts(llm.requests[1]).at(-1) ?? ''
346
- assert(searchResult.includes('fixture_echo'), 'tool_search result omits fixture_echo summary')
395
+ assert(searchResult.includes('dsh_im_return_file'), 'tool_search result omits dsh_im_return_file summary')
347
396
  assert(!searchResult.includes('"parameters"'), 'tool_search result duplicates a full schema')
348
- const echoResult = toolResultTexts(llm.requests[2]).at(-1) ?? ''
349
- assert(echoResult.includes('real-agent-ok'), 'fixture_echo did not execute through the real Agent loop')
397
+ const returnFileResult = toolResultTexts(llm.requests[2]).at(-1) ?? ''
398
+ assert(returnFileResult.includes('"sent":true'), 'dsh_im_return_file did not execute through the real Agent loop')
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
+
350
505
 
351
506
  process.stdout.write(`${JSON.stringify({
352
507
  ok: true,
@@ -356,8 +511,11 @@ try {
356
511
  modelCalls: llm.requests.length,
357
512
  initialToolCount: initialNames.length,
358
513
  selectedToolCount: searchedNames.length,
359
- selectedOnNextCall: searchedNames.includes('fixture_echo'),
360
- package: '@anionex/dsh-tool-search@0.1.0',
514
+ selectedOnNextCall: searchedNames.includes('dsh_im_return_file'),
515
+ presentation,
516
+ modeRejection,
517
+ dshScript,
518
+ package: `@anionex/dsh-tool-search@${packageVersion}`,
361
519
  temporaryHome: keepTemp ? home : undefined,
362
520
  }, null, 2)}\n`)
363
521
  } finally {
@@ -24,7 +24,7 @@ async function exists(path) {
24
24
 
25
25
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
26
26
  check(pkg.name === '@anionex/dsh-tool-search', 'package name is incorrect')
27
- check(pkg.version === '0.1.0', 'release version is incorrect')
27
+ check(pkg.version === '0.1.1', 'release version is incorrect')
28
28
  check(pkg.main === './lib/index.js', 'main entrypoint is incorrect')
29
29
  check(pkg.types === './lib/types/index.d.ts', 'types entrypoint is incorrect')
30
30
  check(pkg.exports?.['./client']?.default === './lib/client.js', 'client export is incorrect')
@@ -73,6 +73,7 @@ const en = {
73
73
  selected: 'Live agents',
74
74
  defaultPolicy: 'Default',
75
75
  alwaysPolicy: 'Always',
76
+ deferredPolicy: 'Deferred',
76
77
  blockedPolicy: 'Blocked',
77
78
  loading: 'Loading...',
78
79
  empty: 'No matching tools',
@@ -93,6 +94,7 @@ const zh: typeof en = {
93
94
  selected: '活跃 Agent',
94
95
  defaultPolicy: '默认',
95
96
  alwaysPolicy: '常驻',
97
+ deferredPolicy: '延迟',
96
98
  blockedPolicy: '禁用',
97
99
  loading: '加载中...',
98
100
  empty: '没有匹配工具',
@@ -296,7 +298,9 @@ export function ToolSearchSettingsSection({ scope, t }: SettingsProps): ReactNod
296
298
  disabled={!snapshot.writable || tool.name === TOOL_SEARCH_NAME}
297
299
  onChange={event => changePolicy(tool.name, event.target.value as ToolPolicy | 'default')}
298
300
  >
299
- <option value="default">{core.has(tool.name) ? `${translate('defaultPolicy')} (${translate('alwaysPolicy')})` : translate('defaultPolicy')}</option>
301
+ <option value="default">
302
+ {`${translate('defaultPolicy')} (${translate(core.has(tool.name) ? 'alwaysPolicy' : 'deferredPolicy')})`}
303
+ </option>
300
304
  <option value="always">{translate('alwaysPolicy')}</option>
301
305
  <option value="blocked">{translate('blockedPolicy')}</option>
302
306
  </select>
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
@@ -16,7 +30,6 @@ export const DEFAULT_CORE_TOOLS = Object.freeze([
16
30
  'ask_user_question',
17
31
  'bash',
18
32
  'create_goal',
19
- 'dsh_im_return_file',
20
33
  'exit_plan_mode',
21
34
  'get_goal',
22
35
  'interrupt_agent',
@@ -23,12 +23,21 @@ describe('Tool Search settings section', () => {
23
23
  description: 'Capture a browser screenshot.',
24
24
  policy: 'deferred',
25
25
  selectedBy: [],
26
+ }, {
27
+ name: 'dsh_im_return_file',
28
+ description: 'Send a readable file to the user.',
29
+ policy: 'deferred',
30
+ selectedBy: [],
26
31
  }],
27
32
  }), { status: 200 }))))
28
33
 
29
34
  render(<ToolSearchSettingsSection scope={scope} />)
30
35
  expect(await screen.findByText('browser_take_screenshot')).toBeDefined()
31
- expect(screen.getByLabelText('Live agents: 0').getAttribute('data-label')).toBe('Live agents')
36
+ const imPolicy = screen.getByLabelText('Policy: dsh_im_return_file') as HTMLSelectElement
37
+ expect(imPolicy.value).toBe('default')
38
+ expect(imPolicy.options[0]?.text).toBe('Default (Deferred)')
39
+ expect(screen.getAllByLabelText('Live agents: 0').map(element => element.getAttribute('data-label')))
40
+ .toEqual(['Live agents', 'Live agents'])
32
41
  fireEvent.change(screen.getByLabelText('Policy: browser_take_screenshot'), { target: { value: 'always' } })
33
42
  await waitFor(() => {
34
43
  expect(set).toHaveBeenCalledWith('alwaysVisible', ['browser_take_screenshot'])