Skip to content

Commit 380e550

Browse files
committed
fix(tool): update ToolProviderItem type to use ChatCompletionTool and adjust related logic
1 parent 10dfcb8 commit 380e550

4 files changed

Lines changed: 196 additions & 15 deletions

File tree

‎docs/src/tools/message.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,10 @@ type ToolSource =
280280
| { type: 'unknown' }
281281
```
282282
283-
`ToolProviderItem` 表示可提供给模型的工具项,可以是普通 OpenAI function tool schema,也可以是带本地执行函数的 runtime tool:
283+
`ToolProviderItem` 表示可提供给模型的工具项,可以是普通 OpenAI tool schema,也可以是带本地执行函数的 runtime tool:
284284
285285
```typescript
286-
type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool
286+
type ToolProviderItem = ChatCompletionTool | RuntimeTool
287287
type AsyncStreamableResult<T> = Promise<T> | AsyncGenerator<T> | Promise<AsyncGenerator<T>>
288288
type MaybeStreamableResult<T> = T | AsyncStreamableResult<T>
289289

@@ -293,6 +293,8 @@ interface RuntimeTool {
293293
}
294294
```
295295

296+
`toolPlugin` 会保留请求体中已有的 OpenAI `custom` tool,也允许 `getTools` / `provideTools` 返回普通 `ChatCompletionTool`。其中只有 function tool 会参与工具名去重、`toolSource` 记录和 runtime handler 路由;runtime tool 的 `tool` 字段仍需是 `ChatCompletionFunctionTool`。
297+
296298
`ToolProvider` 是供插件扩展使用的高级协议。对于使用 `toolPlugin` 的业务代码,通常只需要通过 `getTools` 和 `callTool` 接入工具;当插件本身需要按内部状态向模型暴露工具时,再实现 `provideTools(context)`。
297299

298300
##### 基础示例

‎packages/kit/src/message/plugins/toolPlugin.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ChatCompletionFunctionTool,
44
ChatCompletionMessageFunctionToolCall,
55
ChatCompletionMessageToolCall,
6+
ChatCompletionTool,
67
} from 'openai/resources'
78
import type { MaybePromise, MaybeStreamableResult } from '../../types'
89
import type { BasePluginContext, ChatMessage, MessageEnginePlugin, MutateMessageStateFn } from '../types'
@@ -32,7 +33,7 @@ export interface RuntimeTool {
3233
) => MaybeStreamableResult<string | Record<string, any>>
3334
}
3435

35-
export type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool
36+
export type ToolProviderItem = ChatCompletionTool | RuntimeTool
3637

3738
export interface ToolProvider {
3839
provideTools: (context: BasePluginContext) => MaybePromise<ToolProviderItem[]>
@@ -228,6 +229,10 @@ export const toolPlugin = (
228229
return toolCall.type === 'function' && 'function' in toolCall
229230
}
230231

232+
const isFunctionTool = (tool: ChatCompletionTool): tool is ChatCompletionFunctionTool => {
233+
return tool.type === 'function' && 'function' in tool
234+
}
235+
231236
const isRuntimeTool = (tool: ToolProviderItem): tool is RuntimeTool => {
232237
return Boolean(tool && typeof tool === 'object' && 'tool' in tool && 'handler' in tool)
233238
}
@@ -241,7 +246,18 @@ export const toolPlugin = (
241246
return typeof plugin.disabled === 'function' ? plugin.disabled(context) : Boolean(plugin.disabled)
242247
}
243248

244-
const resolveTools = async (context: BasePluginContext, existingTools: ChatCompletionFunctionTool[] = []) => {
249+
type ResolvedTools = {
250+
tools: ChatCompletionTool[]
251+
runtimeToolMap: Map<string, RuntimeTool>
252+
toolSourceMap: Map<string, ToolSource>
253+
}
254+
255+
let currentToolResolution: ResolvedTools | undefined
256+
257+
const resolveTools = async (
258+
context: BasePluginContext,
259+
existingTools: ChatCompletionTool[] = [],
260+
): Promise<ResolvedTools> => {
245261
const providedToolItems: Array<{ item: ToolProviderItem; source: ToolSource }> = []
246262

247263
for (const plugin of context.plugins) {
@@ -266,7 +282,7 @@ export const toolPlugin = (
266282
source: { type: 'toolPlugin' as const },
267283
})),
268284
]
269-
const tools: ChatCompletionFunctionTool[] = []
285+
const tools: ChatCompletionTool[] = []
270286
const runtimeToolMap = new Map<string, RuntimeTool>()
271287
const toolSourceMap = new Map<string, ToolSource>()
272288
const seenToolNames = new Set<string>()
@@ -283,13 +299,15 @@ export const toolPlugin = (
283299
seenToolNames.add(toolName)
284300
}
285301

286-
existingTools.forEach(registerToolName)
302+
existingTools.filter(isFunctionTool).forEach(registerToolName)
287303

288304
for (const { item: toolItem, source } of toolItems) {
289305
const tool = isRuntimeTool(toolItem) ? toolItem.tool : toolItem
290306

291-
registerToolName(tool)
292-
toolSourceMap.set(tool.function.name, source)
307+
if (isFunctionTool(tool)) {
308+
registerToolName(tool)
309+
toolSourceMap.set(tool.function.name, source)
310+
}
293311

294312
if (isRuntimeTool(toolItem)) {
295313
tools.push(toolItem.tool)
@@ -319,7 +337,9 @@ export const toolPlugin = (
319337
const { requestBody } = context
320338

321339
const existingTools = Array.isArray(requestBody.tools) ? requestBody.tools : []
322-
const { tools } = await resolveTools(context, existingTools)
340+
const resolvedTools = await resolveTools(context, existingTools)
341+
currentToolResolution = resolvedTools
342+
const { tools } = resolvedTools
323343
if (tools && tools.length > 0) {
324344
requestBody.tools = existingTools.length ? [...existingTools, ...tools] : tools
325345
}
@@ -348,7 +368,10 @@ export const toolPlugin = (
348368
assistantMessage: currentMessage as AssistantMessageWithState,
349369
})
350370

351-
const { runtimeToolMap, toolSourceMap } = await resolveTools(context)
371+
const { runtimeToolMap, toolSourceMap } = currentToolResolution ?? {
372+
runtimeToolMap: new Map<string, RuntimeTool>(),
373+
toolSourceMap: new Map<string, ToolSource>(),
374+
}
352375

353376
const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => {
354377
const now = Math.floor(Date.now() / 1000)
@@ -445,6 +468,7 @@ export const toolPlugin = (
445468
})
446469

447470
await Promise.all(toolCallPromises)
471+
currentToolResolution = undefined
448472
if (!abortSignal.aborted) {
449473
requestNext()
450474
}

‎packages/kit/src/message/test/toolPlugin.test.ts‎

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ChatCompletion } from 'openai/resources'
1+
import type { ChatCompletion, ChatCompletionFunctionTool, ChatCompletionTool } from 'openai/resources'
22
import { describe, expect, it, vi } from 'vitest'
33
import { createNativeMessageAdapter } from '../adapters/native'
44
import { createMessageEngine } from '../core/engine'
@@ -10,6 +10,11 @@ const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({
1010
const createTestMessageEngine = (options: CreateMessageEngineOptions) =>
1111
createMessageEngine(createNativeMessageAdapter(), options)
1212

13+
const isFunctionTool = (tool: ChatCompletionTool): tool is ChatCompletionFunctionTool => tool.type === 'function'
14+
15+
const functionToolNames = (tools: ChatCompletionTool[] = []) =>
16+
tools.filter(isFunctionTool).map((tool) => tool.function.name)
17+
1318
describe('toolPlugin', () => {
1419
it('injects and executes runtime tools before falling back to callTool', async () => {
1520
const runtimeCall = vi.fn(() => ({ result: 'runtime-result' }))
@@ -35,7 +40,7 @@ describe('toolPlugin', () => {
3540
const hasToolResult = requestBody.messages.some((message) => message.role === 'tool')
3641

3742
if (!hasToolResult) {
38-
expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['runtime_lookup'])
43+
expect(functionToolNames(requestBody.tools)).toEqual(['runtime_lookup'])
3944
return {
4045
id: 'tool-call',
4146
object: 'chat.completion',
@@ -203,7 +208,7 @@ describe('toolPlugin', () => {
203208
const hasToolResult = requestBody.messages.some((message) => message.role === 'tool')
204209

205210
if (!hasToolResult) {
206-
expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['provided_tool'])
211+
expect(functionToolNames(requestBody.tools)).toEqual(['provided_tool'])
207212

208213
return {
209214
id: 'provider-tool-call',
@@ -290,4 +295,154 @@ describe('toolPlugin', () => {
290295
}),
291296
)
292297
})
298+
299+
it('keeps runtime tool handlers stable for the tool list sent to the model', async () => {
300+
const runtimeCall = vi.fn(() => 'runtime result')
301+
const fallbackCall = vi.fn(() => 'fallback result')
302+
const runtimeTool: RuntimeTool = {
303+
tool: {
304+
type: 'function',
305+
function: {
306+
name: 'volatile_runtime_tool',
307+
description: 'Runtime tool that is only available during request preparation',
308+
},
309+
},
310+
handler: runtimeCall,
311+
}
312+
let getToolsCalls = 0
313+
const responseProvider = vi.fn<ResponseProvider>(async (requestBody) => {
314+
const hasToolResult = requestBody.messages.some((message) => message.role === 'tool')
315+
316+
if (!hasToolResult) {
317+
expect(functionToolNames(requestBody.tools)).toEqual(['volatile_runtime_tool'])
318+
319+
return {
320+
id: 'volatile-tool-call',
321+
object: 'chat.completion',
322+
created: Math.floor(Date.now() / 1000),
323+
model: 'mock',
324+
choices: [
325+
{
326+
index: 0,
327+
message: {
328+
role: 'assistant',
329+
content: '',
330+
tool_calls: [
331+
{
332+
id: 'call-volatile',
333+
type: 'function',
334+
function: {
335+
name: 'volatile_runtime_tool',
336+
arguments: '{}',
337+
},
338+
},
339+
],
340+
},
341+
finish_reason: 'tool_calls',
342+
},
343+
],
344+
} as ChatCompletion
345+
}
346+
347+
expect(requestBody.messages.at(-1)).toMatchObject({
348+
role: 'tool',
349+
tool_call_id: 'call-volatile',
350+
content: 'runtime result',
351+
})
352+
353+
return {
354+
id: 'final-answer',
355+
object: 'chat.completion',
356+
created: Math.floor(Date.now() / 1000),
357+
model: 'mock',
358+
choices: [
359+
{
360+
index: 0,
361+
message: {
362+
role: 'assistant',
363+
content: 'done',
364+
},
365+
finish_reason: 'stop',
366+
},
367+
],
368+
} as ChatCompletion
369+
})
370+
371+
const engine = createTestMessageEngine({
372+
plugins: [
373+
...silentDefaultPlugins,
374+
toolPlugin({
375+
getTools: async () => {
376+
getToolsCalls++
377+
return getToolsCalls === 1 ? [runtimeTool] : []
378+
},
379+
callTool: fallbackCall,
380+
}),
381+
],
382+
responseProvider,
383+
})
384+
385+
await engine.sendMessage('call volatile tool')
386+
387+
expect(runtimeCall).toHaveBeenCalledOnce()
388+
expect(fallbackCall).not.toHaveBeenCalled()
389+
})
390+
391+
it('keeps custom tools already present on the request body', async () => {
392+
const customTool = {
393+
type: 'custom',
394+
custom: {
395+
name: 'custom_formatter',
396+
description: 'Format with custom grammar',
397+
format: {
398+
type: 'grammar',
399+
grammar: {
400+
syntax: 'lark',
401+
definition: 'start: "ok"',
402+
},
403+
},
404+
},
405+
} satisfies ChatCompletionTool
406+
const responseProvider = vi.fn<ResponseProvider>(async (requestBody) => {
407+
expect(requestBody.tools).toEqual([customTool])
408+
409+
return {
410+
id: 'final-answer',
411+
object: 'chat.completion',
412+
created: Math.floor(Date.now() / 1000),
413+
model: 'mock',
414+
choices: [
415+
{
416+
index: 0,
417+
message: {
418+
role: 'assistant',
419+
content: 'done',
420+
},
421+
finish_reason: 'stop',
422+
},
423+
],
424+
} as ChatCompletion
425+
})
426+
427+
const engine = createTestMessageEngine({
428+
plugins: [
429+
...silentDefaultPlugins,
430+
{
431+
name: 'custom-tool-plugin',
432+
onBeforeRequest: (context) => {
433+
context.requestBody.tools = [customTool]
434+
},
435+
},
436+
toolPlugin({
437+
getTools: async () => [],
438+
callTool: async () => 'fallback',
439+
}),
440+
],
441+
responseProvider,
442+
})
443+
444+
await engine.sendMessage('use custom tool')
445+
446+
expect(responseProvider).toHaveBeenCalledOnce()
447+
})
293448
})

‎packages/kit/src/message/types.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import {
33
ChatCompletion,
44
ChatCompletionChunk,
5-
ChatCompletionFunctionTool,
5+
ChatCompletionTool,
66
ChatCompletionMessageParam,
77
ChatCompletionMessageToolCall,
88
} from 'openai/resources'
@@ -33,7 +33,7 @@ export type ChatMessage<
3333

3434
export interface MessageRequestBody {
3535
messages: Array<ChatMessage>
36-
tools?: Array<ChatCompletionFunctionTool>
36+
tools?: Array<ChatCompletionTool>
3737
[key: string]: any
3838
}
3939

0 commit comments

Comments
 (0)