@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
2
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
3
|
+
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
4
|
+
import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { ToolSearchRuntime } from '../src/runtime.ts'
|
|
7
|
+
import { TOOL_SEARCH_NAME } from '../src/shared.ts'
|
|
8
|
+
|
|
9
|
+
describe('real ToolRuntime execution regression', () => {
|
|
10
|
+
it('preserves approval, guard, around, body, post, and result semantics for a deferred tool', async () => {
|
|
11
|
+
const ctx = new Context()
|
|
12
|
+
const promptFiber = ctx.plugin(SystemPrompt, {})
|
|
13
|
+
await promptFiber.await()
|
|
14
|
+
const toolFiber = ctx.plugin(ToolRuntime, { mode: 'native' })
|
|
15
|
+
await toolFiber.await()
|
|
16
|
+
|
|
17
|
+
const order: string[] = []
|
|
18
|
+
const approval = vi.fn(() => {
|
|
19
|
+
order.push('approval')
|
|
20
|
+
return Promise.resolve('allowed-once' as const)
|
|
21
|
+
})
|
|
22
|
+
ctx.provide('approval', { request: approval } as never)
|
|
23
|
+
ctx.provide('agents', { list: () => [] } as never)
|
|
24
|
+
|
|
25
|
+
ctx.on('tools/pre-execute', async () => {
|
|
26
|
+
order.push('pre')
|
|
27
|
+
return { kind: 'ask', reason: 'regression approval' }
|
|
28
|
+
})
|
|
29
|
+
ctx.tools.guard(() => {
|
|
30
|
+
order.push('guard')
|
|
31
|
+
return undefined
|
|
32
|
+
})
|
|
33
|
+
ctx.on('tools/execute', async (_exec, next) => {
|
|
34
|
+
order.push('around:before')
|
|
35
|
+
const result = await next()
|
|
36
|
+
order.push('around:after')
|
|
37
|
+
return result
|
|
38
|
+
})
|
|
39
|
+
ctx.on('tools/post-execute', async (_exec, _result, next) => {
|
|
40
|
+
order.push('post')
|
|
41
|
+
return next()
|
|
42
|
+
})
|
|
43
|
+
ctx.on('tools/result', () => { order.push('result') })
|
|
44
|
+
|
|
45
|
+
ctx.tools.register(defineTool({
|
|
46
|
+
name: 'deferred_dangerous_operation',
|
|
47
|
+
description: 'A deferred operation used by the pipeline regression test.',
|
|
48
|
+
parameters: {},
|
|
49
|
+
output: {
|
|
50
|
+
schema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
properties: { ok: { type: 'boolean', required: true } },
|
|
54
|
+
},
|
|
55
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
56
|
+
},
|
|
57
|
+
execute: () => {
|
|
58
|
+
order.push('body')
|
|
59
|
+
return Promise.resolve({ ok: true })
|
|
60
|
+
},
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
const assemblyListeners: Array<(...args: never[]) => unknown> = []
|
|
64
|
+
const agent = {
|
|
65
|
+
id: 'pipeline-agent',
|
|
66
|
+
session: { events: [] },
|
|
67
|
+
ctx: {
|
|
68
|
+
on: (_name: string, listener: (...args: never[]) => unknown) => {
|
|
69
|
+
assemblyListeners.push(listener)
|
|
70
|
+
return () => {}
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
} as unknown as Agent
|
|
74
|
+
const search = new ToolSearchRuntime(ctx, {
|
|
75
|
+
defaultLimit: 5,
|
|
76
|
+
alwaysVisible: [],
|
|
77
|
+
neverSearch: [],
|
|
78
|
+
})
|
|
79
|
+
const disposeSearch = search.install()
|
|
80
|
+
search.selectedFor(agent)
|
|
81
|
+
|
|
82
|
+
const result = await ctx.tools.execute({
|
|
83
|
+
callId: 'pipeline-call' as never,
|
|
84
|
+
name: 'deferred_dangerous_operation',
|
|
85
|
+
arguments: {},
|
|
86
|
+
agent,
|
|
87
|
+
signal: new AbortController().signal,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(result.isError).toBe(false)
|
|
91
|
+
expect(approval).toHaveBeenCalledOnce()
|
|
92
|
+
expect(order).toEqual([
|
|
93
|
+
'pre',
|
|
94
|
+
'approval',
|
|
95
|
+
'guard',
|
|
96
|
+
'around:before',
|
|
97
|
+
'body',
|
|
98
|
+
'around:after',
|
|
99
|
+
'post',
|
|
100
|
+
'result',
|
|
101
|
+
])
|
|
102
|
+
expect(ctx.tools.get('deferred_dangerous_operation', agent)).toBeDefined()
|
|
103
|
+
expect(assemblyListeners).toHaveLength(1)
|
|
104
|
+
disposeSearch()
|
|
105
|
+
await toolFiber.dispose()
|
|
106
|
+
await promptFiber.dispose()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('commits selection after final success and preserves a recoverable canonical result', async () => {
|
|
110
|
+
const ctx = new Context()
|
|
111
|
+
const promptFiber = ctx.plugin(SystemPrompt, {})
|
|
112
|
+
await promptFiber.await()
|
|
113
|
+
const toolFiber = ctx.plugin(ToolRuntime, { mode: 'native' })
|
|
114
|
+
await toolFiber.await()
|
|
115
|
+
ctx.provide('agents', { list: () => [] } as never)
|
|
116
|
+
|
|
117
|
+
ctx.tools.register(defineTool({
|
|
118
|
+
name: 'browser_take_screenshot',
|
|
119
|
+
description: 'Capture a screenshot of a browser page.',
|
|
120
|
+
parameters: {},
|
|
121
|
+
output: {
|
|
122
|
+
schema: { type: 'string' },
|
|
123
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
124
|
+
},
|
|
125
|
+
execute: () => Promise.resolve('captured'),
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
|
129
|
+
if (exec.name !== TOOL_SEARCH_NAME) return next()
|
|
130
|
+
if ((exec.arguments as { query?: string }).query?.includes('blocked') === true) {
|
|
131
|
+
return { kind: 'block', feedback: [{ type: 'text', text: 'blocked after body' }] }
|
|
132
|
+
}
|
|
133
|
+
return { kind: 'accept', content: [{ type: 'text', text: 'post-execute replacement' }] }
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const agent = {
|
|
137
|
+
id: 'search-commit-agent',
|
|
138
|
+
session: { events: [] },
|
|
139
|
+
ctx: { on: () => () => {} },
|
|
140
|
+
} as unknown as Agent
|
|
141
|
+
const search = new ToolSearchRuntime(ctx, {
|
|
142
|
+
defaultLimit: 5,
|
|
143
|
+
alwaysVisible: [],
|
|
144
|
+
neverSearch: [],
|
|
145
|
+
})
|
|
146
|
+
ctx.tools.register(search.definition)
|
|
147
|
+
const disposeSearch = search.install()
|
|
148
|
+
|
|
149
|
+
const blocked = await ctx.tools.execute({
|
|
150
|
+
callId: 'blocked-search' as never,
|
|
151
|
+
name: TOOL_SEARCH_NAME,
|
|
152
|
+
arguments: { query: 'browser screenshot blocked', limit: 1 },
|
|
153
|
+
agent,
|
|
154
|
+
signal: new AbortController().signal,
|
|
155
|
+
})
|
|
156
|
+
expect(blocked.isError).toBe(true)
|
|
157
|
+
expect([...search.selectedFor(agent)]).toEqual([])
|
|
158
|
+
|
|
159
|
+
const accepted = await ctx.tools.execute({
|
|
160
|
+
callId: 'accepted-search' as never,
|
|
161
|
+
name: TOOL_SEARCH_NAME,
|
|
162
|
+
arguments: { query: 'browser screenshot', limit: 1 },
|
|
163
|
+
agent,
|
|
164
|
+
signal: new AbortController().signal,
|
|
165
|
+
})
|
|
166
|
+
expect(accepted.isError).toBe(false)
|
|
167
|
+
expect([...search.selectedFor(agent)]).toEqual(['browser_take_screenshot'])
|
|
168
|
+
const text = accepted.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
169
|
+
expect(text).not.toBe('post-execute replacement')
|
|
170
|
+
expect(JSON.parse(text).tools[0].name).toBe('browser_take_screenshot')
|
|
171
|
+
|
|
172
|
+
const resumedAgent = {
|
|
173
|
+
id: 'resumed-search-agent',
|
|
174
|
+
session: {
|
|
175
|
+
events: [{
|
|
176
|
+
type: 'tool/call',
|
|
177
|
+
data: { callId: 'accepted-search', name: TOOL_SEARCH_NAME, arguments: '{"query":"browser screenshot"}' },
|
|
178
|
+
}, {
|
|
179
|
+
type: 'tool/result',
|
|
180
|
+
data: {
|
|
181
|
+
message: {
|
|
182
|
+
content: [{
|
|
183
|
+
type: 'tool-result',
|
|
184
|
+
toolCallId: 'accepted-search',
|
|
185
|
+
isError: false,
|
|
186
|
+
content: accepted.content,
|
|
187
|
+
}],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}],
|
|
191
|
+
},
|
|
192
|
+
ctx: { on: () => () => {} },
|
|
193
|
+
} as unknown as Agent
|
|
194
|
+
expect([...search.selectedFor(resumedAgent)]).toEqual(['browser_take_screenshot'])
|
|
195
|
+
|
|
196
|
+
disposeSearch()
|
|
197
|
+
await toolFiber.dispose()
|
|
198
|
+
await promptFiber.dispose()
|
|
199
|
+
})
|
|
200
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
2
|
+
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { ToolSchemaLike } from '../src/catalog.ts'
|
|
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
|
+
function schema(name: string, description: string): ToolSchemaLike {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
description,
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: {
|
|
24
|
+
target: { type: 'string', description: `${description} target.` },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeAgent(id: string, events: unknown[] = [], sessionApi: 'events' | 'snapshot' = 'events') {
|
|
31
|
+
let assemblyListener: AssemblyListener | undefined
|
|
32
|
+
const agent = {
|
|
33
|
+
id,
|
|
34
|
+
session: sessionApi === 'snapshot' ? { snapshotEvents: () => events } : { events },
|
|
35
|
+
ctx: {
|
|
36
|
+
on(name: string, listener: AssemblyListener) {
|
|
37
|
+
if (name === 'system-prompt/assemble') assemblyListener = listener
|
|
38
|
+
return () => {
|
|
39
|
+
if (assemblyListener === listener) assemblyListener = undefined
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
} as unknown as Agent
|
|
44
|
+
return {
|
|
45
|
+
agent,
|
|
46
|
+
assemble(assembly: PromptAssembly): Promise<PromptAssembly> {
|
|
47
|
+
if (assemblyListener === undefined) throw new Error('assembly listener was not installed')
|
|
48
|
+
return assemblyListener(assembly, {}, () => Promise.resolve(assembly))
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function harness(options: {
|
|
54
|
+
agents?: ReturnType<typeof makeAgent>[]
|
|
55
|
+
settings?: Partial<ToolSearchSettings>
|
|
56
|
+
schemas?: ToolSchemaLike[]
|
|
57
|
+
} = {}) {
|
|
58
|
+
const agents = options.agents ?? [makeAgent('agent-1')]
|
|
59
|
+
let schemas = options.schemas ?? [
|
|
60
|
+
schema('apply_patch', 'Edit files with a patch'),
|
|
61
|
+
schema(TOOL_SEARCH_NAME, 'Search deferred tools'),
|
|
62
|
+
schema('browser_take_screenshot', 'Capture a browser screenshot'),
|
|
63
|
+
schema('web_search', 'Search current public web sources'),
|
|
64
|
+
schema('read_image', 'Inspect a local image'),
|
|
65
|
+
]
|
|
66
|
+
const listeners = new Map<string, RootListener[]>()
|
|
67
|
+
const ctx = {
|
|
68
|
+
agents: { list: () => agents.map(item => item.agent) },
|
|
69
|
+
tools: { schemas: () => schemas },
|
|
70
|
+
logger: { warn: vi.fn() },
|
|
71
|
+
on(name: string, listener: RootListener) {
|
|
72
|
+
const rows = listeners.get(name) ?? []
|
|
73
|
+
rows.push(listener)
|
|
74
|
+
listeners.set(name, rows)
|
|
75
|
+
return () => listeners.set(name, rows.filter(row => row !== listener))
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
const runtime = new ToolSearchRuntime(ctx as never, {
|
|
79
|
+
defaultLimit: 5,
|
|
80
|
+
alwaysVisible: [],
|
|
81
|
+
neverSearch: [],
|
|
82
|
+
...options.settings,
|
|
83
|
+
})
|
|
84
|
+
const dispose = runtime.install()
|
|
85
|
+
const assembly = (): PromptAssembly => ({
|
|
86
|
+
sections: schemas.map(tool => ({ name: `tool:${tool.name}`, text: `${tool.name} guidance` })),
|
|
87
|
+
contexts: [],
|
|
88
|
+
tools: schemas as never,
|
|
89
|
+
variables: {},
|
|
90
|
+
})
|
|
91
|
+
return {
|
|
92
|
+
agents,
|
|
93
|
+
runtime,
|
|
94
|
+
dispose,
|
|
95
|
+
setSchemas(next: ToolSchemaLike[]) { schemas = next },
|
|
96
|
+
emit(name: string, ...args: unknown[]) {
|
|
97
|
+
for (const listener of listeners.get(name) ?? []) listener(...args)
|
|
98
|
+
},
|
|
99
|
+
assembly,
|
|
100
|
+
async search(agent: Agent, query: string, limit?: number): Promise<ToolSearchResult> {
|
|
101
|
+
const exec = {
|
|
102
|
+
agent,
|
|
103
|
+
name: TOOL_SEARCH_NAME,
|
|
104
|
+
callId: 'call-search',
|
|
105
|
+
rootCallId: 'call-search',
|
|
106
|
+
token: Symbol('test-token'),
|
|
107
|
+
arguments: { query },
|
|
108
|
+
signal: new AbortController().signal,
|
|
109
|
+
}
|
|
110
|
+
const value = await runtime.definition.execute(
|
|
111
|
+
{ query, ...(limit === undefined ? {} : { limit }) },
|
|
112
|
+
exec as never,
|
|
113
|
+
) as ToolSearchResult
|
|
114
|
+
this.emit('tools/result', exec, {
|
|
115
|
+
isError: false,
|
|
116
|
+
value,
|
|
117
|
+
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
118
|
+
})
|
|
119
|
+
return value
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function recoveryEvents(selected: string[]): unknown[] {
|
|
125
|
+
return [{
|
|
126
|
+
type: 'tool/call',
|
|
127
|
+
data: { callId: 'search-1', name: TOOL_SEARCH_NAME, arguments: '{"query":"image"}' },
|
|
128
|
+
}, {
|
|
129
|
+
type: 'tool/result',
|
|
130
|
+
data: {
|
|
131
|
+
message: {
|
|
132
|
+
content: [{
|
|
133
|
+
type: 'tool-result',
|
|
134
|
+
toolCallId: 'search-1',
|
|
135
|
+
isError: false,
|
|
136
|
+
content: [{
|
|
137
|
+
type: 'text',
|
|
138
|
+
text: JSON.stringify({
|
|
139
|
+
schemaVersion: 1,
|
|
140
|
+
query: 'image',
|
|
141
|
+
tools: selected.map(name => ({ name, description: '', score: 1 })),
|
|
142
|
+
}),
|
|
143
|
+
}],
|
|
144
|
+
}],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe('ToolSearchRuntime', () => {
|
|
151
|
+
it('reveals deferred schemas on the next assembly and never returns full schemas', async () => {
|
|
152
|
+
const app = harness()
|
|
153
|
+
const initial = await app.agents[0]!.assemble(app.assembly())
|
|
154
|
+
expect(initial.tools.map(tool => tool.name)).toEqual(['apply_patch', TOOL_SEARCH_NAME])
|
|
155
|
+
expect(initial.sections.map(section => section.name)).toEqual(['tool:apply_patch', `tool:${TOOL_SEARCH_NAME}`])
|
|
156
|
+
|
|
157
|
+
let modelCalls = 1
|
|
158
|
+
const result = await app.search(app.agents[0]!.agent, 'browser_take_screenshot', 1)
|
|
159
|
+
expect(JSON.stringify(result)).not.toMatch(/parameters|properties|schema\s*:/u)
|
|
160
|
+
expect(result.tools.map(tool => tool.name)).toEqual(['browser_take_screenshot'])
|
|
161
|
+
|
|
162
|
+
modelCalls += 1
|
|
163
|
+
const next = await app.agents[0]!.assemble(app.assembly())
|
|
164
|
+
expect(next.tools.map(tool => tool.name)).toEqual(['apply_patch', TOOL_SEARCH_NAME, 'browser_take_screenshot'])
|
|
165
|
+
expect(modelCalls).toBe(2)
|
|
166
|
+
app.dispose()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('keeps selected sets isolated per agent and monotonically growing', async () => {
|
|
170
|
+
const first = makeAgent('first')
|
|
171
|
+
const second = makeAgent('second')
|
|
172
|
+
const app = harness({ agents: [first, second] })
|
|
173
|
+
await app.search(first.agent, 'browser_take_screenshot', 1)
|
|
174
|
+
await app.search(first.agent, 'web_search', 1)
|
|
175
|
+
expect([...app.runtime.selectedFor(first.agent)].sort()).toEqual(['browser_take_screenshot', 'web_search'])
|
|
176
|
+
expect([...app.runtime.selectedFor(second.agent)]).toEqual([])
|
|
177
|
+
expect((await first.assemble(app.assembly())).tools.map(tool => tool.name)).toContain('web_search')
|
|
178
|
+
expect((await second.assemble(app.assembly())).tools.map(tool => tool.name)).not.toContain('web_search')
|
|
179
|
+
app.dispose()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('recovers selected sets independently from resume and fork history prefixes', async () => {
|
|
183
|
+
const resume = makeAgent('resume', recoveryEvents(['read_image', 'web_search']), 'snapshot')
|
|
184
|
+
const fork = makeAgent('fork', recoveryEvents(['read_image']))
|
|
185
|
+
const app = harness({ agents: [resume, fork] })
|
|
186
|
+
expect([...app.runtime.selectedFor(resume.agent)].sort()).toEqual(['read_image', 'web_search'])
|
|
187
|
+
expect([...app.runtime.selectedFor(fork.agent)]).toEqual(['read_image'])
|
|
188
|
+
expect((await resume.assemble(app.assembly())).tools.map(tool => tool.name)).toContain('web_search')
|
|
189
|
+
expect((await fork.assemble(app.assembly())).tools.map(tool => tool.name)).not.toContain('web_search')
|
|
190
|
+
app.dispose()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('invalidates every agent catalog when registry metadata changes', async () => {
|
|
194
|
+
const app = harness()
|
|
195
|
+
await app.agents[0]!.assemble(app.assembly())
|
|
196
|
+
app.setSchemas([
|
|
197
|
+
...app.assembly().tools as never,
|
|
198
|
+
schema('browser_pdf_export', 'Export the browser page as a PDF document'),
|
|
199
|
+
])
|
|
200
|
+
app.emit('tools/change')
|
|
201
|
+
const result = await app.search(app.agents[0]!.agent, 'browser_pdf_export', 1)
|
|
202
|
+
expect(result.tools[0]?.name).toBe('browser_pdf_export')
|
|
203
|
+
app.dispose()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('applies live policies without deleting selected history', async () => {
|
|
207
|
+
const app = harness()
|
|
208
|
+
await app.search(app.agents[0]!.agent, 'web_search', 1)
|
|
209
|
+
app.runtime.updateSettings({
|
|
210
|
+
defaultLimit: 5,
|
|
211
|
+
alwaysVisible: ['read_image'],
|
|
212
|
+
neverSearch: ['web_search'],
|
|
213
|
+
})
|
|
214
|
+
const blocked = await app.agents[0]!.assemble(app.assembly())
|
|
215
|
+
expect(blocked.tools.map(tool => tool.name)).toContain('read_image')
|
|
216
|
+
expect(blocked.tools.map(tool => tool.name)).not.toContain('web_search')
|
|
217
|
+
expect(app.runtime.selectedFor(app.agents[0]!.agent).has('web_search')).toBe(true)
|
|
218
|
+
app.runtime.updateSettings({ defaultLimit: 5, alwaysVisible: [], neverSearch: [] })
|
|
219
|
+
expect((await app.agents[0]!.assemble(app.assembly())).tools.map(tool => tool.name)).toContain('web_search')
|
|
220
|
+
app.dispose()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('commits selections only from final successful tool results', async () => {
|
|
224
|
+
const app = harness()
|
|
225
|
+
const agent = app.agents[0]!.agent
|
|
226
|
+
const exec = {
|
|
227
|
+
agent,
|
|
228
|
+
name: TOOL_SEARCH_NAME,
|
|
229
|
+
callId: 'pending-search',
|
|
230
|
+
rootCallId: 'pending-search',
|
|
231
|
+
token: Symbol('pending-search'),
|
|
232
|
+
arguments: { query: 'web_search' },
|
|
233
|
+
signal: new AbortController().signal,
|
|
234
|
+
}
|
|
235
|
+
const value = await app.runtime.definition.execute({ query: 'web_search', limit: 1 }, exec as never) as ToolSearchResult
|
|
236
|
+
expect([...app.runtime.selectedFor(agent)]).toEqual([])
|
|
237
|
+
|
|
238
|
+
app.emit('tools/result', exec, {
|
|
239
|
+
isError: true,
|
|
240
|
+
error: { message: 'blocked after execution' },
|
|
241
|
+
content: [{ type: 'text', text: 'blocked' }],
|
|
242
|
+
})
|
|
243
|
+
expect([...app.runtime.selectedFor(agent)]).toEqual([])
|
|
244
|
+
|
|
245
|
+
app.emit('tools/result', exec, {
|
|
246
|
+
isError: false,
|
|
247
|
+
value,
|
|
248
|
+
content: [{ type: 'text', text: 'post-execute replacement' }],
|
|
249
|
+
})
|
|
250
|
+
expect([...app.runtime.selectedFor(agent)]).toEqual(['web_search'])
|
|
251
|
+
app.dispose()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('rejects code and both presentation assemblies', async () => {
|
|
255
|
+
const app = harness()
|
|
256
|
+
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)
|
|
259
|
+
app.dispose()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('accepts an empty SDK section from an Agent-scoped native override', async () => {
|
|
263
|
+
const app = harness()
|
|
264
|
+
const assembly = app.assembly()
|
|
265
|
+
assembly.sections.push({ name: 'tools:sdk', text: '' })
|
|
266
|
+
await expect(app.agents[0]!.assemble(assembly)).resolves.toMatchObject({
|
|
267
|
+
tools: [{ name: 'apply_patch' }, { name: TOOL_SEARCH_NAME }],
|
|
268
|
+
})
|
|
269
|
+
app.dispose()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ToolPolicyResolver, normalizeSettings } from '../src/settings.ts'
|
|
3
|
+
import { TOOL_SEARCH_NAME } from '../src/shared.ts'
|
|
4
|
+
|
|
5
|
+
describe('tool exposure policy', () => {
|
|
6
|
+
it('normalizes exact-name lists deterministically', () => {
|
|
7
|
+
expect(normalizeSettings({
|
|
8
|
+
defaultLimit: 200,
|
|
9
|
+
alwaysVisible: [' zeta ', 'alpha', '', 'alpha'],
|
|
10
|
+
neverSearch: ['danger', TOOL_SEARCH_NAME, 'danger'],
|
|
11
|
+
})).toEqual({
|
|
12
|
+
defaultLimit: 20,
|
|
13
|
+
alwaysVisible: ['alpha', 'zeta'],
|
|
14
|
+
neverSearch: ['danger'],
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps core tools visible, defers long-tail tools, and lets blacklist win', () => {
|
|
19
|
+
const policy = new ToolPolicyResolver({
|
|
20
|
+
defaultLimit: 5,
|
|
21
|
+
alwaysVisible: ['custom_tool', 'conflict_tool'],
|
|
22
|
+
neverSearch: ['conflict_tool', 'bash'],
|
|
23
|
+
})
|
|
24
|
+
expect(policy.classify(TOOL_SEARCH_NAME)).toBe('always')
|
|
25
|
+
expect(policy.classify('apply_patch')).toBe('always')
|
|
26
|
+
expect(policy.classify('report')).toBe('always')
|
|
27
|
+
expect(policy.classify('bash')).toBe('blocked')
|
|
28
|
+
expect(policy.classify('custom_tool')).toBe('always')
|
|
29
|
+
expect(policy.classify('conflict_tool')).toBe('blocked')
|
|
30
|
+
expect(policy.classify('browser_take_screenshot')).toBe('deferred')
|
|
31
|
+
expect(policy.classify('browser_take_screenshot', new Set(['browser_take_screenshot']))).toBe('always')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"declaration": true,
|
|
9
|
+
"declarationMap": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"rewriteRelativeImportExtensions": true,
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": ".client-build",
|
|
15
|
+
"declarationDir": "lib/types",
|
|
16
|
+
"strict": true,
|
|
17
|
+
"noUncheckedIndexedAccess": true,
|
|
18
|
+
"exactOptionalPropertyTypes": true,
|
|
19
|
+
"noImplicitOverride": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUnusedLocals": true,
|
|
22
|
+
"noUnusedParameters": true,
|
|
23
|
+
"skipLibCheck": true,
|
|
24
|
+
"esModuleInterop": true,
|
|
25
|
+
"verbatimModuleSyntax": false,
|
|
26
|
+
"noEmitOnError": true,
|
|
27
|
+
"types": ["react", "react-dom"]
|
|
28
|
+
},
|
|
29
|
+
"include": ["src/shared.ts", "src/client/**/*.ts", "src/client/**/*.tsx"],
|
|
30
|
+
"exclude": ["tests", "lib", ".client-build", "node_modules"]
|
|
31
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2023"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"rewriteRelativeImportExtensions": true,
|
|
12
|
+
"rootDir": "src",
|
|
13
|
+
"outDir": "lib",
|
|
14
|
+
"declarationDir": "./lib/types",
|
|
15
|
+
"strict": true,
|
|
16
|
+
"noUncheckedIndexedAccess": true,
|
|
17
|
+
"exactOptionalPropertyTypes": true,
|
|
18
|
+
"noImplicitOverride": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true,
|
|
20
|
+
"noUnusedLocals": true,
|
|
21
|
+
"noUnusedParameters": true,
|
|
22
|
+
"skipLibCheck": true,
|
|
23
|
+
"esModuleInterop": true,
|
|
24
|
+
"verbatimModuleSyntax": false,
|
|
25
|
+
"noEmitOnError": true,
|
|
26
|
+
"types": ["node"]
|
|
27
|
+
},
|
|
28
|
+
"include": ["src/**/*.ts"],
|
|
29
|
+
"exclude": ["src/client", "tests", "lib", ".client-build", "node_modules"]
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"exactOptionalPropertyTypes": true,
|
|
12
|
+
"noImplicitOverride": true,
|
|
13
|
+
"noFallthroughCasesInSwitch": true,
|
|
14
|
+
"noUnusedLocals": true,
|
|
15
|
+
"noUnusedParameters": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"esModuleInterop": true,
|
|
18
|
+
"verbatimModuleSyntax": false,
|
|
19
|
+
"types": ["node", "react", "react-dom", "vitest/globals"]
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"],
|
|
22
|
+
"exclude": ["lib", ".client-build", "node_modules"]
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineConfig } from 'tsdown'
|
|
2
|
+
|
|
3
|
+
const id = '@anionex/dsh-tool-search'
|
|
4
|
+
const platformModules = [
|
|
5
|
+
'react',
|
|
6
|
+
'react/jsx-runtime',
|
|
7
|
+
'react-dom',
|
|
8
|
+
'react-dom/client',
|
|
9
|
+
'@deepseek-ai/cordis',
|
|
10
|
+
'@deepseek-ai/dsh-client-runtime/client',
|
|
11
|
+
'@deepseek-ai/dsh-client-ui-settings/client',
|
|
12
|
+
'@deepseek-ai/dsh-client-ui-slots',
|
|
13
|
+
'@deepseek-ai/dsh-client-locale/client',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
entry: { client: 'src/client/index.tsx' },
|
|
18
|
+
format: 'cjs',
|
|
19
|
+
platform: 'browser',
|
|
20
|
+
target: 'es2022',
|
|
21
|
+
outDir: 'lib',
|
|
22
|
+
clean: false,
|
|
23
|
+
sourcemap: true,
|
|
24
|
+
dts: false,
|
|
25
|
+
minify: false,
|
|
26
|
+
deps: {
|
|
27
|
+
neverBundle: platformModules,
|
|
28
|
+
alwaysBundle: dependency => platformModules.includes(dependency) ? undefined : true,
|
|
29
|
+
onlyBundle: false,
|
|
30
|
+
},
|
|
31
|
+
outputOptions: {
|
|
32
|
+
entryFileNames: 'client.js',
|
|
33
|
+
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
|
|
34
|
+
footer: 'return module.exports; } });',
|
|
35
|
+
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
|
36
|
+
},
|
|
37
|
+
})
|