Skip to content

Commit f2a3775

Browse files
committed
feat: kb_service_list, kb_search, kb_chat tool factory
1 parent 5800222 commit f2a3775

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/**
2+
* The three model-facing knowledge tools. Schemas are static per deployment: a configured
3+
* defaultAgentId downgrades agent_id to optional at build time (never a runtime fallback chain).
4+
*/
5+
6+
import { defineTool } from '@deepseek-ai/dsh-tools'
7+
import type { SearchRequest, SearchResponse } from './api-types.js'
8+
import { KbApiError, type KbClient } from './client.js'
9+
import { consumeChatStream } from './chat.js'
10+
import { KB_PATHS } from './endpoints.js'
11+
import { listServices } from './services.js'
12+
13+
/** Client-side chunk cap applied when the model omits top_k. */
14+
const DEFAULT_TOP_K = 5
15+
16+
export interface KbToolDeps {
17+
client: KbClient
18+
defaultAgentId?: string
19+
chatTimeoutMs: number
20+
}
21+
22+
/** Format one service list into the error-hint / result text form. */
23+
function formatServices(services: { agent_id: string; name: string; scene: string; status: string }[]): string {
24+
return services.map(s => `${s.agent_id} (${s.name}, scene=${s.scene}, ${s.status})`).join('; ')
25+
}
26+
27+
/**
28+
* Append the current service list to a client error so the model can correct
29+
* an invalid agent_id in one step. Auth failures (401/403) keep their own message.
30+
* @param client - the shared knowledge API client used for best-effort discovery.
31+
* @param err - the failure being enriched; always rethrown.
32+
* @returns never; the original or enriched error is thrown.
33+
*/
34+
async function withServiceHint(client: KbClient, err: unknown): Promise<never> {
35+
if (err instanceof KbApiError && err.status !== undefined && err.status >= 400 && err.status !== 401 && err.status !== 403) {
36+
let hint: string | undefined
37+
try {
38+
const { services } = await listServices(client, {})
39+
if (services.length > 0) hint = formatServices(services)
40+
} catch { /* discovery is best-effort; the original error already carries the failure */ }
41+
if (hint !== undefined) throw new KbApiError(`${err.message}. Available services: ${hint}`, err.status)
42+
}
43+
throw err
44+
}
45+
46+
/**
47+
* Build the three tool definitions over one shared client.
48+
* @param deps - client plus the deployment's explicit pinning and timeout choices.
49+
* @returns definitions ready for `ctx.tools.register()`.
50+
*/
51+
export function createKbTools(deps: KbToolDeps) {
52+
const { client, defaultAgentId, chatTimeoutMs } = deps
53+
const agentIdParam = {
54+
type: 'string' as const,
55+
...(defaultAgentId === undefined ? { required: true as const } : {}),
56+
description: defaultAgentId === undefined
57+
? 'Retrieval/Q&A service id (find one via kb_service_list).'
58+
: 'Retrieval/Q&A service id; omit to use this deployment\'s default service.',
59+
}
60+
const resolveAgentId = (supplied: string | undefined): string => {
61+
const agentId = supplied ?? defaultAgentId
62+
if (agentId === undefined) throw new Error('agent_id is required: discover services with kb_service_list')
63+
return agentId
64+
}
65+
66+
const serviceList = defineTool({
67+
name: 'kb_service_list',
68+
description:
69+
'List the Bailian knowledge retrieval/Q&A services available in this workspace. '
70+
+ 'Each entry names the service id (agent_id) to pass to kb_search (scene=search) or kb_chat (scene=chat), '
71+
+ 'its bound knowledge bases, and its status (prefer deployed). '
72+
+ 'Omit scene to see both kinds; narrow large workspaces with name_filter.',
73+
parameters: {
74+
scene: { type: 'string', enum: ['chat', 'search'], description: 'Only list services for this scene; omitted lists both.' },
75+
name_filter: { type: 'string', description: 'Fuzzy match on the service name.' },
76+
},
77+
output: {
78+
schema: {
79+
type: 'object',
80+
additionalProperties: false,
81+
properties: {
82+
services: {
83+
type: 'array',
84+
required: true,
85+
items: {
86+
type: 'object',
87+
additionalProperties: false,
88+
properties: {
89+
agent_id: { type: 'string', required: true },
90+
name: { type: 'string', required: true },
91+
scene: { type: 'string', required: true },
92+
status: { type: 'string', required: true },
93+
knowledge_bases: { type: 'array', required: true, items: { type: 'string' } },
94+
},
95+
},
96+
},
97+
total: { type: 'integer', required: true },
98+
truncated: { type: 'boolean', required: true },
99+
},
100+
},
101+
render: (_args, value) => [{
102+
type: 'text',
103+
text: value.services.length === 0
104+
? 'No knowledge services found.'
105+
: `${value.services.length} service(s): ${formatServices(value.services)}`
106+
+ (value.truncated ? ` — listed first ${value.services.length} of ${value.total}; narrow with name_filter.` : ''),
107+
}],
108+
},
109+
async execute(args) {
110+
return await listServices(client, {
111+
...(args.scene === 'chat' || args.scene === 'search' ? { scene: args.scene } : {}),
112+
...(args.name_filter ? { nameFilter: args.name_filter } : {}),
113+
})
114+
},
115+
presentCall: args => ({ card: 'generic', title: 'List knowledge services', kind: 'other', rawInput: args }),
116+
})
117+
118+
const search = defineTool({
119+
name: 'kb_search',
120+
description:
121+
'Semantic search over a Bailian knowledge base. Returns raw knowledge chunks with scores and source '
122+
+ 'references for you to verify, cite, or combine with other context. Retrieval scope and strategy '
123+
+ '(multi-KB weighting, routing, reranking) come from the service configuration. '
124+
+ 'top_k caps how many chunks return (client-side cut of the score-ranked results). '
125+
+ 'Use kb_chat instead when the user question can be answered by the knowledge base alone.',
126+
parameters: {
127+
query: { type: 'string', required: true, description: 'Search query text.' },
128+
agent_id: agentIdParam,
129+
top_k: { type: 'integer', description: `Maximum chunks to return; defaults to ${DEFAULT_TOP_K}.` },
130+
images: { type: 'array', items: { type: 'string' }, description: 'Image URLs for multimodal retrieval.' },
131+
},
132+
output: {
133+
schema: {
134+
type: 'object',
135+
additionalProperties: false,
136+
properties: {
137+
chunks: {
138+
type: 'array',
139+
required: true,
140+
items: {
141+
type: 'object',
142+
additionalProperties: false,
143+
properties: {
144+
text: { type: 'string', required: true },
145+
score: { type: 'number', required: true },
146+
doc_name: { type: 'string' },
147+
doc_id: { type: 'string' },
148+
title: { type: 'string' },
149+
},
150+
},
151+
},
152+
total: { type: 'integer', required: true },
153+
},
154+
},
155+
render: (_args, value) => [{
156+
type: 'text',
157+
text: value.chunks.length === 0
158+
? 'No matching knowledge chunks.'
159+
: value.chunks.map((c, i) => `[${i + 1}] (score ${c.score.toFixed(2)}${c.doc_name ? `, ${c.doc_name}` : ''}) ${c.text}`).join('\n'),
160+
}],
161+
},
162+
async execute(args) {
163+
const topK = args.top_k ?? DEFAULT_TOP_K
164+
const body: SearchRequest = {
165+
query: args.query,
166+
agent_id: resolveAgentId(args.agent_id),
167+
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
168+
...(args.images && args.images.length > 0 ? { images: args.images } : {}),
169+
}
170+
const res = await client.postJson<SearchResponse>(KB_PATHS.search, body).catch(err => withServiceHint(client, err))
171+
const nodes = (res.data?.nodes ?? []).slice(0, topK)
172+
return {
173+
chunks: nodes.map(n => ({
174+
text: n.text,
175+
score: n.score,
176+
...(typeof n.metadata?.doc_name === 'string' ? { doc_name: n.metadata.doc_name } : {}),
177+
...(typeof n.metadata?.doc_id === 'string' ? { doc_id: n.metadata.doc_id } : {}),
178+
...(typeof n.metadata?.title === 'string' ? { title: n.metadata.title } : {}),
179+
})),
180+
total: res.data?.total ?? nodes.length,
181+
}
182+
},
183+
presentCall: args => ({ card: 'generic', title: 'Search knowledge base', kind: 'search', rawInput: args }),
184+
})
185+
186+
const chat = defineTool({
187+
name: 'kb_chat',
188+
description:
189+
'Ask the knowledge base directly and get a complete, domain-tuned answer from a specialized RAG pipeline '
190+
+ '(multi-round retrieval + reranking + grounded generation). For knowledge Q&A this typically outperforms '
191+
+ 'searching and synthesizing yourself when the question can be answered by the knowledge base alone; '
192+
+ 'use kb_search instead when you need raw chunks to verify, cite, or combine with other work. '
193+
+ 'The pipeline runs an internal analysis/retrieval loop and may take a few minutes.',
194+
parameters: {
195+
message: { type: 'string', required: true, description: 'The question to ask.' },
196+
agent_id: agentIdParam,
197+
},
198+
output: {
199+
schema: {
200+
type: 'object',
201+
additionalProperties: false,
202+
properties: {
203+
answer: { type: 'string', required: true },
204+
request_id: { type: 'string' },
205+
},
206+
},
207+
render: (_args, value) => [{ type: 'text', text: value.answer.length === 0 ? '(empty answer)' : value.answer }],
208+
},
209+
async execute(args) {
210+
const body = {
211+
input: { messages: [{ role: 'user' as const, content: args.message }] },
212+
parameters: { agent_options: {
213+
agent_id: resolveAgentId(args.agent_id),
214+
...(client.agentVersion ? { agent_version: client.agentVersion } : {}),
215+
} },
216+
stream: true as const,
217+
}
218+
let res: Response
219+
try {
220+
res = await client.postSse(KB_PATHS.chat, body, AbortSignal.timeout(chatTimeoutMs))
221+
} catch (err) {
222+
if (err instanceof Error && err.name === 'TimeoutError') {
223+
throw new Error(
224+
`knowledge chat timed out after ${chatTimeoutMs}ms; the pipeline runs a multi-round retrieval loop `
225+
+ 'and long questions can exceed the deployment timeout. Retry, or use kb_search for raw chunks instead.',
226+
)
227+
}
228+
return await withServiceHint(client, err)
229+
}
230+
const { answer, requestId } = await consumeChatStream(res)
231+
return { answer, ...(requestId ? { request_id: requestId } : {}) }
232+
},
233+
presentCall: args => ({ card: 'generic', title: 'Ask knowledge base (may take a few minutes)', kind: 'fetch', rawInput: args }),
234+
})
235+
236+
return [serviceList, search, chat]
237+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { KbApiError, KbClient } from '../src/client.js'
3+
import { createKbTools } from '../src/tools.js'
4+
5+
const EXEC = {} as never
6+
7+
function toolsWith(postJson: unknown, postSse?: unknown, defaultAgentId?: string) {
8+
const client = { postJson, postSse, agentVersion: undefined } as unknown as KbClient
9+
const list = createKbTools({ client, ...(defaultAgentId ? { defaultAgentId } : {}), chatTimeoutMs: 1000 })
10+
const byName = Object.fromEntries(list.map(t => [t.name, t]))
11+
return { byName, list }
12+
}
13+
14+
const searchResponse = {
15+
request_id: 'r1',
16+
data: { total: 3, nodes: [
17+
{ score: 0.9, text: 'A', metadata: { doc_name: 'd1' } },
18+
{ score: 0.8, text: 'B', metadata: {} },
19+
{ score: 0.7, text: 'C', metadata: {} },
20+
] },
21+
}
22+
23+
describe('createKbTools', () => {
24+
it('registers exactly kb_service_list, kb_search, kb_chat', () => {
25+
const { list } = toolsWith(vi.fn())
26+
expect(list.map(t => t.name).sort()).toEqual(['kb_chat', 'kb_search', 'kb_service_list'])
27+
})
28+
29+
it('kb_search truncates nodes client-side to top_k and never sends top_k to the server', async () => {
30+
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse)
31+
const { byName } = toolsWith(postJson)
32+
const out = await byName.kb_search!.execute({ query: 'q', agent_id: 'aid-1', top_k: 2 }, EXEC) as { chunks: unknown[] }
33+
expect(out.chunks).toHaveLength(2)
34+
const body = postJson.mock.calls[0]![1] as Record<string, unknown>
35+
expect(body).not.toHaveProperty('top_k')
36+
expect(body.agent_id).toBe('aid-1')
37+
})
38+
39+
it('agent_id is required without defaultAgentId and optional with one', () => {
40+
const withoutDefault = toolsWith(vi.fn()).byName.kb_search!
41+
const withDefault = toolsWith(vi.fn(), undefined, 'aid-fixed').byName.kb_search!
42+
// defineTool compiles the spec into JSON Schema: requiredness lives in the top-level `required` array.
43+
const requiredList = (tool: { parameters: Record<string, unknown> }) =>
44+
(tool.parameters.required ?? []) as string[]
45+
expect(requiredList(withoutDefault)).toContain('agent_id')
46+
expect(requiredList(withDefault)).not.toContain('agent_id')
47+
})
48+
49+
it('kb_search falls back to defaultAgentId as an explicit resolve step', async () => {
50+
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse)
51+
const { byName } = toolsWith(postJson, undefined, 'aid-fixed')
52+
await byName.kb_search!.execute({ query: 'q' }, EXEC)
53+
expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_id).toBe('aid-fixed')
54+
})
55+
56+
it('a 4xx failure appends the current service list to the error', async () => {
57+
const postJson = vi.fn(async (path: string) => {
58+
if (path === '/api/v1/indices/knowledge/search') throw new KbApiError('agent not found', 400)
59+
return { data: { total_count: 1, rows: [{ agent_id: 'aid-9', agent_name: 'faq', agent_scene: 'search', agent_status: 'deployed' }] } }
60+
})
61+
const { byName } = toolsWith(postJson)
62+
const err = await byName.kb_search!.execute({ query: 'q', agent_id: 'bad' }, EXEC).catch((e: unknown) => e)
63+
expect((err as Error).message).toContain('aid-9')
64+
})
65+
66+
it('kb_chat buffers the SSE stream into one answer', async () => {
67+
const sse = 'data: {"output":{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]},"request_id":"r2"}\n\ndata: [DONE]\n\n'
68+
const postSse = vi.fn(async () => new Response(sse, { status: 200 }))
69+
const { byName } = toolsWith(vi.fn(), postSse)
70+
const out = await byName.kb_chat!.execute({ message: 'q', agent_id: 'aid-1' }, EXEC) as { answer: string }
71+
expect(out.answer).toBe('hi')
72+
})
73+
74+
it('kb_chat translates a timeout into retry-or-search guidance', async () => {
75+
const timeout = Object.assign(new Error('operation timed out'), { name: 'TimeoutError' })
76+
const postSse = vi.fn(async () => { throw timeout })
77+
const { byName } = toolsWith(vi.fn(), postSse)
78+
const err = await byName.kb_chat!.execute({ message: 'q', agent_id: 'aid-1' }, EXEC).catch((e: unknown) => e)
79+
expect((err as Error).message).toMatch(/timed out.*kb_search/s)
80+
})
81+
})

0 commit comments

Comments
 (0)