Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions docs/src/tools/message.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,14 @@ useMessage({

用于接入模型返回的 `tool_calls`:在请求前注入 `tools` 列表,在请求完成后解析 `tool_calls`、执行 `callTool`、追加 tool 消息并自动发起下一轮请求。支持取消/失败时补充或标记 tool 消息、下一轮是否排除 tool 消息等。**需显式添加到 `plugins` 数组才会生效**。

`toolPlugin` 也是 message 插件体系中的工具聚合入口。除自身的 `getTools` 外,具备工具能力的插件可以通过 `ToolProvider` 协议暴露 `provideTools(context)`,让 `toolPlugin` 在 `onBeforeRequest` 阶段统一收集并写入最终发送给模型的 `requestBody.tools`。这适合让能力型插件按自己的状态提供工具,例如 skill 文件工具、运行时工具或业务上下文相关工具。

工具来源会写入工具调用上下文的 `toolSource` 字段,便于在 `callTool`、`onToolCallStart`、`onToolCallEnd` 中做日志、分流或调试。`toolPlugin.getTools` 提供的工具来源为 `{ type: 'toolPlugin' }`;其他插件通过 `ToolProvider.provideTools` 提供的工具来源为 `{ type: 'toolProvider', pluginName?: string }`;无法识别来源时为 `{ type: 'unknown' }`。

| 参数 | 类型 | 必填 | 默认值 | 说明 |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getTools` | `() => Promise<Tool[]>` | 是 | - | 返回当前轮次要传给 API 的工具列表(OpenAI 格式)。 |
| `callTool` | `(toolCall, context) => Promise<string \| Record<string, any>> \| AsyncGenerator<string \| Record<string, any>>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。 |
| `getTools` | `(context: BasePluginContext) => MaybePromise<ToolProviderItem[]>` | 是 | - | 返回当前轮次要传给 API 的工具列表。可以返回普通 OpenAI tool schema,也可以返回带执行函数的 runtime tool。 |
| `callTool` | `(toolCall, context) => MaybeStreamableResult<string \| Record<string, unknown>>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。可通过 `context.toolSource` 判断工具来源。 |
| `beforeCallTools` | `(toolCalls, context) => Promise<void>` | 否 | - | 在真正执行工具前调用,可用于统一校验、鉴权、埋点。新字段为 `context.assistantMessage`;`context.currentMessage` 继续保留,但已弃用。 |
| `onToolCallStart` | `(toolCall, context) => void` | 否 | - | 单个工具开始执行时触发。此时对应的 tool 消息已经创建并追加到 `messages` 中;`context` 额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`。 |
| `onToolCallEnd` | `(toolCall, context) => void` | 否 | - | 单个工具执行结束时触发。`context.status` 为 `'success' \| 'failed' \| 'cancelled'`,并额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`,失败或取消时可能有 `context.error`。 |
Expand All @@ -263,9 +267,33 @@ useMessage({
| 回调 | 额外上下文字段 | 说明 |
| ----------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `beforeCallTools` | `assistantMessage`、`currentMessage`(已弃用) | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 |
| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息,以及当前工具对应的 `toolMessage`。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 |
| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息和当前 tool 消息。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 |
| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 |
| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息、当前工具对应的 `toolMessage` 和工具来源。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 |
| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息、当前 tool 消息和工具来源。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 |
| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息、工具来源和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 |

`toolSource` 类型:

```typescript
type ToolSource =
| { type: 'toolPlugin' }
| { type: 'toolProvider'; pluginName?: string }
| { type: 'unknown' }
```

`ToolProviderItem` 表示可提供给模型的工具项,可以是普通 OpenAI function tool schema,也可以是带本地执行函数的 runtime tool:

```typescript
type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool
type AsyncStreamableResult<T> = Promise<T> | AsyncGenerator<T> | Promise<AsyncGenerator<T>>
type MaybeStreamableResult<T> = T | AsyncStreamableResult<T>

interface RuntimeTool {
tool: ChatCompletionFunctionTool
handler: (toolCall, context) => MaybeStreamableResult<string | Record<string, unknown>>
}
```

`ToolProvider` 是供插件扩展使用的高级协议。对于使用 `toolPlugin` 的业务代码,通常只需要通过 `getTools` 和 `callTool` 接入工具;当插件本身需要按内部状态向模型暴露工具时,再实现 `provideTools(context)`。

##### 基础示例

Expand All @@ -292,8 +320,9 @@ useMessage({
},
},
],
callTool: async (toolCall) => {
callTool: async (toolCall, context) => {
const args = JSON.parse(toolCall.function?.arguments || '{}')
console.log('Tool source:', context.toolSource)
return `Weather of ${args.city}: Sunny.`
},
onToolCallEnd: (toolCall, { status }) => console.log('Tool end:', status),
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/message/core/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChatCompletion, ChatCompletionChunk } from 'openai/resources/index'
import { ChatCompletion, ChatCompletionChunk } from 'openai/resources'
import { lengthPlugin, thinkingPlugin } from '../plugins'
import {
BasePluginContext,
Expand Down Expand Up @@ -156,6 +156,7 @@ export const createMessageEngine = (
mutate,
abortSignal,
currentTurn: runtime.currentTurn,
plugins,
customContext: runtime.customContext,
setRequestState,
setCustomContext,
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/message/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { lengthPlugin } from './lengthPlugin'
export { thinkingPlugin } from './thinkingPlugin'
export { toolPlugin } from './toolPlugin'
export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem, ToolSource } from './toolPlugin'
136 changes: 127 additions & 9 deletions packages/kit/src/message/plugins/toolPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources/index'
import {
ChatCompletionFunctionTool,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageToolCall,
} from 'openai/resources'
import type { MaybePromise, MaybeStreamableResult } from '../../types'
import type { BasePluginContext, ChatMessage, MessageEnginePlugin, MutateMessageStateFn } from '../types'
import { combineDeltaData, normalizeToAsyncGenerator } from '../utils'

Expand All @@ -8,9 +13,29 @@ type AssistantMessageWithState = ChatMessage<
{ toolCall?: Record<string, Record<string, unknown>> }
>

type ToolCallContext = BasePluginContext & {
export type ToolSource = { type: 'toolPlugin' } | { type: 'toolProvider'; pluginName?: string } | { type: 'unknown' }

export type ToolCallContext = BasePluginContext & {
assistantMessage: AssistantMessageWithState
toolMessage: ChatMessage
/**
* 当前工具的来源。
*/
toolSource: ToolSource
}

export interface RuntimeTool {
tool: ChatCompletionFunctionTool
handler: (
toolCall: ChatCompletionMessageFunctionToolCall,
context: ToolCallContext,
) => MaybeStreamableResult<string | Record<string, any>>
}

export type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool

export interface ToolProvider {
provideTools: (context: BasePluginContext) => MaybePromise<ToolProviderItem[]>
}

/**
Expand Down Expand Up @@ -99,9 +124,9 @@ function fillMissingToolMessages({
export const toolPlugin = (
options: MessageEnginePlugin & {
/**
* 获取工具列表的函数。会在请求大模型前调用
* 获取本轮可用工具。可以返回普通 tool schema,也可以返回带执行函数的 runtime tool
*/
getTools: () => Promise<ChatCompletionTool[]>
getTools: (context: BasePluginContext) => MaybePromise<ToolProviderItem[]>
/**
* 在处理包含 tool_calls 的响应前调用。
*/
Expand All @@ -115,7 +140,7 @@ export const toolPlugin = (
callTool: (
toolCall: ChatCompletionMessageToolCall,
context: ToolCallContext,
) => Promise<string | Record<string, any>> | AsyncGenerator<string | Record<string, any>>
) => MaybeStreamableResult<string | Record<string, any>>
/**
* 工具调用开始时的回调函数。
* 触发时机:工具消息已创建并追加后,调用 callTool 之前触发。
Expand Down Expand Up @@ -197,6 +222,86 @@ export const toolPlugin = (
onToolCallEnd?.(...args)
}

const isFunctionToolCall = (
toolCall: ChatCompletionMessageToolCall,
): toolCall is ChatCompletionMessageFunctionToolCall => {
return toolCall.type === 'function' && 'function' in toolCall
}

const isRuntimeTool = (tool: ToolProviderItem): tool is RuntimeTool => {
return Boolean(tool && typeof tool === 'object' && 'tool' in tool && 'handler' in tool)
}

const getToolProvider = (plugin: MessageEnginePlugin): ToolProvider | undefined => {
const toolProvider = plugin as Partial<ToolProvider>
return typeof toolProvider.provideTools === 'function' ? (toolProvider as ToolProvider) : undefined
}

const isPluginDisabled = (plugin: MessageEnginePlugin, context: BasePluginContext) => {
return typeof plugin.disabled === 'function' ? plugin.disabled(context) : Boolean(plugin.disabled)
}

const resolveTools = async (context: BasePluginContext, existingTools: ChatCompletionFunctionTool[] = []) => {
const providedToolItems: Array<{ item: ToolProviderItem; source: ToolSource }> = []

for (const plugin of context.plugins) {
const toolProvider = getToolProvider(plugin)
if (!isPluginDisabled(plugin, context) && toolProvider) {
providedToolItems.push(
...(await toolProvider.provideTools(context)).map((item) => ({
item,
source: {
type: 'toolProvider' as const,
pluginName: plugin.name,
},
})),
)
}
}

const toolItems = [
...providedToolItems,
...(await getTools(context)).map((item) => ({
item,
source: { type: 'toolPlugin' as const },
})),
]
const tools: ChatCompletionFunctionTool[] = []
const runtimeToolMap = new Map<string, RuntimeTool>()
const toolSourceMap = new Map<string, ToolSource>()
const seenToolNames = new Set<string>()

const registerToolName = (tool: ChatCompletionFunctionTool) => {
const toolName = tool.function.name

if (seenToolNames.has(toolName)) {
throw new Error(
`Duplicate tool name "${toolName}" detected. Tool names must be unique because tool calls are routed by function.name.`,
)
}

seenToolNames.add(toolName)
}

existingTools.forEach(registerToolName)

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

registerToolName(tool)
toolSourceMap.set(tool.function.name, source)

if (isRuntimeTool(toolItem)) {
tools.push(toolItem.tool)
runtimeToolMap.set(toolItem.tool.function.name, toolItem)
} else {
tools.push(toolItem)
}
}

return { tools, runtimeToolMap, toolSourceMap }
}

return {
name: 'tool',
...restOptions,
Expand All @@ -213,9 +318,10 @@ export const toolPlugin = (
onBeforeRequest: async (context) => {
const { requestBody } = context

const tools = await getTools()
const existingTools = Array.isArray(requestBody.tools) ? requestBody.tools : []
const { tools } = await resolveTools(context, existingTools)
if (tools && tools.length > 0) {
requestBody.tools = tools
requestBody.tools = existingTools.length ? [...existingTools, ...tools] : tools
}

return restOptions.onBeforeRequest?.(context)
Expand All @@ -242,6 +348,8 @@ export const toolPlugin = (
assistantMessage: currentMessage as AssistantMessageWithState,
})

const { runtimeToolMap, toolSourceMap } = await resolveTools(context)

const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => {
const now = Math.floor(Date.now() / 1000)
let hasMeaningfulResult = false
Expand All @@ -257,15 +365,25 @@ export const toolPlugin = (

appendMessage(toolMessage)

const contextWithToolMessage = {
const functionToolCall = isFunctionToolCall(toolCall) ? toolCall : undefined
const toolSource = functionToolCall
? (toolSourceMap.get(functionToolCall.function.name) ?? { type: 'unknown' as const })
: { type: 'unknown' as const }

const contextWithToolMessage: ToolCallContext = {
...context,
assistantMessage: currentMessage as AssistantMessageWithState,
toolMessage,
toolSource,
}

toolCallStart(toolCall, contextWithToolMessage)
try {
const result = callTool(toolCall, contextWithToolMessage)
const runtimeTool = functionToolCall ? runtimeToolMap.get(functionToolCall.function.name) : undefined
const result =
runtimeTool && functionToolCall
? runtimeTool.handler(functionToolCall, contextWithToolMessage)
: callTool(toolCall, contextWithToolMessage)

// 将 Promise 或异步迭代器统一转换为异步生成器
const iterator = normalizeToAsyncGenerator(result)
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/message/test/mockResponseProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ChatCompletionChunk } from 'openai/resources/index'
import type { ChatCompletionChunk } from 'openai/resources'
import type { ResponseProvider } from '../types'
import { AbortError } from '../utils'

Expand Down
Loading
Loading