-
@@ -187,6 +230,7 @@ const handleClick = () => {
flex-shrink: 0;
display: flex;
align-items: center;
+ gap: 8px;
}
.header-icon {
@@ -198,12 +242,14 @@ const handleClick = () => {
animation: spin 1s linear infinite;
}
+ &.icon-awaiting-approval,
&.icon-success {
color: #898989;
}
&.icon-failed,
- &.icon-cancelled {
+ &.icon-cancelled,
+ &.icon-denied {
color: var(--tr-color-error);
}
}
@@ -218,6 +264,52 @@ const handleClick = () => {
}
}
+.header.is-approval {
+ .header-left {
+ color: var(--tr-text-secondary);
+ }
+
+ .header-icon {
+ color: var(--tr-icon-color-default);
+ }
+}
+
+.approval-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-left: 28px;
+ margin-top: 8px;
+}
+
+.approval-button {
+ height: 24px;
+ min-width: 56px;
+ padding: 0 16px;
+ border-radius: 12px;
+ border: 1px solid var(--tr-text-secondary);
+ background: transparent;
+ color: var(--tr-text-primary);
+ font-size: 12px;
+ line-height: 18px;
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--tr-text-primary);
+ }
+
+ &--allow {
+ border-color: var(--tr-text-primary);
+ background: var(--tr-text-primary);
+ color: var(--tr-container-bg-default);
+
+ &:hover {
+ background: var(--tr-text-secondary);
+ border-color: var(--tr-text-secondary);
+ }
+ }
+}
+
.divider {
margin: 12px 0;
border-top: 1px solid rgb(219, 219, 219);
diff --git a/packages/kit/TODO.md b/packages/kit/TODO.md
new file mode 100644
index 000000000..678c33f87
--- /dev/null
+++ b/packages/kit/TODO.md
@@ -0,0 +1,70 @@
+# TODO: useMessage 工具结果等待状态
+
+## 背景
+
+当前 `submitToolResult` 已经支持通过追加 `role: 'tool'` 消息来继续工具调用流程:当最近一个 assistant message 的 `tool_calls` 都有对应 tool result 后,kit 会自动发起下一轮请求。
+
+后续还需要把“等待 tool result 提交”显式纳入 `useMessage` 的状态模型,避免只依赖 message state 判断流程位置。这样状态可以被序列化存储,页面关闭、会话切换或应用重启后,也能恢复到可继续处理的 pause/resume 状态。
+
+## 目标
+
+- 为 `useMessage` 增加一个等待工具结果提交的 processing 状态。
+- 让等待状态可以随消息一起序列化存储。
+- 恢复会话后,UI 能根据状态继续展示待处理工具调用。
+- 用户允许或拒绝后,业务侧调用 `submitToolResult`,kit 自动判断是否继续请求。
+
+## 建议状态
+
+当前 `requestState` 仍保持:
+
+- `idle`
+- `processing`
+- `completed`
+- `aborted`
+- `error`
+
+建议扩展 `processingState`:
+
+- `requesting`:正在请求模型。
+- `completing`:正在消费模型响应。
+- `calling-tools`:正在自动执行工具。
+- `awaiting-tool-results`:模型已返回 `tool_calls`,但仍有工具结果未提交。
+
+`awaiting-tool-results` 的语义是:当前没有运行中的模型请求,也没有运行中的自动工具调用;流程暂停在等待外部提交 tool result 的阶段。
+
+## 序列化模型
+
+建议最小化存储以下信息:
+
+- `messages`:完整消息列表,包括 assistant message 上的 `tool_calls` 和 `state.toolCall`。
+- `requestState`:建议恢复为 `completed` 或新增更明确的暂停态后再决定。
+- `processingState`:恢复时可以从消息推导,也可以持久化 `awaiting-tool-results`。
+- pending tool call ids:优先从最近一个 assistant message 的 `tool_calls` 与其后的 tool messages 差集推导。
+
+不要在内存里保存 Promise、resolver 或运行时闭包。pause/resume 只能依赖可序列化的数据。
+
+## 实现清单
+
+1. 增加 `RequestProcessingState` 可选值:`awaiting-tool-results`。
+2. 当 `toolPlugin` 发现存在需要确认的工具调用时:
+ - 标记对应 `state.toolCall[id].status = 'awaiting-approval'`。
+ - 将 `processingState` 置为 `awaiting-tool-results`。
+ - 停止本轮自动 `requestNext`。
+3. `submitToolResult` 提交 tool message 后:
+ - 更新对应 tool call 状态。
+ - 如果仍有缺失结果,保持 `awaiting-tool-results`。
+ - 如果全部结果齐全,进入下一轮 `requesting`。
+4. 恢复会话时:
+ - 根据最近一个 assistant message 的 `tool_calls` 和后续 tool messages 推导是否仍在等待。
+ - 若仍有缺失结果,恢复 `processingState = 'awaiting-tool-results'`。
+5. UI 层:
+ - 根据 `state.toolCall[id].status === 'awaiting-approval'` 展示允许/拒绝按钮。
+ - 根据全局 `processingState === 'awaiting-tool-results'` 展示“等待工具结果”类状态。
+
+## 注意点
+
+- 不要把 `await confirmToolCall` 作为恢复机制,刷新页面后 Promise 无法恢复。
+- `submitToolResult` 应只允许提交到最近一个带 `tool_calls` 的 assistant message 后面。
+- 如果最近一个 assistant message 没有 `tool_calls`,提交 tool result 应该直接拒绝或警告。
+- 多个并行 tool call 可以部分提交,但只有全部结果齐全后才继续请求。
+- deny 也应表现为一条 `role: 'tool'` 消息,而不是单纯前端状态。
diff --git a/packages/kit/src/message/core/engine.ts b/packages/kit/src/message/core/engine.ts
index fcb467014..a4c6a414c 100644
--- a/packages/kit/src/message/core/engine.ts
+++ b/packages/kit/src/message/core/engine.ts
@@ -87,6 +87,7 @@ export const createMessageEngine = (
currentTurn: [],
customContext: {},
abortController: null,
+ currentTurnResponseProvider: null,
responseProvider: initialResponseProvider,
}
@@ -149,6 +150,67 @@ export const createMessageEngine = (
return runtimeMessages
}
+ const findLastAssistantMessageWithToolCalls = () => {
+ const messages = getState().messages
+ const lastAssistant = messages
+ .map((message, index) => ({ message, index }))
+ .slice()
+ .reverse()
+ .find(({ message }) => message.role === 'assistant')
+
+ return lastAssistant?.message.tool_calls?.length ? lastAssistant : undefined
+ }
+
+ const getToolMessagesAfter = (index: number) => {
+ const messages = getState().messages
+ const nextMessages = messages.slice(index + 1)
+ const nextAssistantIndex = nextMessages.findIndex((message) => message.role === 'assistant')
+ const messagesBeforeNextAssistant =
+ nextAssistantIndex === -1 ? nextMessages : nextMessages.slice(0, nextAssistantIndex)
+
+ return messagesBeforeNextAssistant.filter((message) => message.role === 'tool')
+ }
+
+ const allToolCallsHaveResults = (assistantMessage: ChatMessage, toolMessages: ChatMessage[]) => {
+ const expectedIds = assistantMessage.tool_calls?.map((toolCall) => toolCall.id) ?? []
+ const resultIds = new Set(toolMessages.map((message) => message.tool_call_id).filter(Boolean))
+
+ return expectedIds.length > 0 && expectedIds.every((id) => resultIds.has(id))
+ }
+
+ const isAwaitingToolResults = () => {
+ const state = getState()
+ return state.requestState === 'processing' && state.processingState === 'awaiting-tool-results'
+ }
+
+ const updateSubmittedToolCallStates = (assistantMessageIndex: number, toolMessages: ChatMessage[]) => {
+ mutate('messages', (draft) => {
+ const assistantMessage = draft.messages[assistantMessageIndex]
+ if (!assistantMessage) {
+ return
+ }
+
+ assistantMessage.state ??= {}
+ const state = assistantMessage.state as Record
+ state.toolCall ??= {}
+ const toolCallState = state.toolCall as Record>
+
+ for (const toolMessage of toolMessages) {
+ const toolCallId = toolMessage.tool_call_id
+ if (!toolCallId) {
+ continue
+ }
+
+ toolCallState[toolCallId] ??= {}
+ const currentStatus = toolCallState[toolCallId].status
+ toolCallState[toolCallId].status =
+ toolMessage.metadata?.toolCallStatus ??
+ (currentStatus === 'awaiting-approval' ? 'success' : currentStatus) ??
+ 'success'
+ }
+ })
+ }
+
// Create base context for plugins
const getBaseContext = (abortSignal: AbortSignal): BasePluginContext => ({
getState,
@@ -320,14 +382,105 @@ export const createMessageEngine = (
}
}
+ async function runTurnEnd(abortSignal: AbortSignal) {
+ const baseContextAtEnd = getBaseContext(abortSignal)
+ for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, baseContextAtEnd))) {
+ await plugin.onTurnEnd?.(baseContextAtEnd)
+ }
+ }
+
+ function cleanupTurn(abortSignal: AbortSignal, assistantMessage: ChatMessage | null) {
+ const context = getBaseContext(abortSignal)
+ for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, context))) {
+ try {
+ plugin.onFinally?.(context)
+ } catch (error) {
+ console.error(`Error in onFinally hook for plugin [${plugin.name || 'Anonymous'}]:`, error)
+ }
+ }
+
+ runtime.abortController = null
+ runtime.currentTurnResponseProvider = null
+ runtime.currentTurn = []
+
+ // 如果请求立即出错,loading 可能一直为 true,这时需要手动将其置为 false
+ mutate('messages', (_, skipNotify) => {
+ if (assistantMessage?.loading) {
+ assistantMessage.loading = undefined
+ } else {
+ skipNotify()
+ }
+ })
+ }
+
+ async function continueTurn() {
+ const ac = runtime.abortController ?? new AbortController()
+ runtime.abortController = ac
+
+ let assistantMessage: ChatMessage | null = null
+ let paused = false
+ const setAssistantMessage = (message: ChatMessage) => {
+ assistantMessage = message
+ }
+
+ try {
+ const turnResponseProvider = runtime.currentTurnResponseProvider ?? runtime.responseProvider
+
+ try {
+ await executeRequest(turnResponseProvider, ac.signal, { setAssistantMessage })
+
+ if (isAwaitingToolResults()) {
+ paused = true
+ return
+ }
+
+ setRequestState('completed')
+ } catch (error) {
+ if (
+ ac.signal.aborted ||
+ error instanceof AbortError ||
+ (error instanceof Error && error.name === 'AbortError')
+ ) {
+ setRequestState('aborted')
+ } else {
+ throw error
+ }
+ }
+
+ await runTurnEnd(ac.signal)
+ } catch (error) {
+ setRequestState('error')
+
+ let hasOnError = false
+ const context = getBaseContext(ac.signal)
+
+ for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, context))) {
+ if (plugin.onError) {
+ hasOnError = true
+ plugin.onError({ ...context, error })
+ }
+ }
+
+ if (!hasOnError) {
+ throw error
+ }
+ } finally {
+ if (!paused) {
+ cleanupTurn(ac.signal, assistantMessage)
+ }
+ }
+ }
+
async function runTurn() {
const ac = new AbortController()
runtime.abortController = ac
// 在每个回合开始时重置自定义上下文
runtime.customContext = {}
+ runtime.currentTurnResponseProvider = null
// 记录当前请求的 assistantMessage,方便在 finally 中进行状态清理(如将 loading 置为 false)
let assistantMessage: ChatMessage | null = null
+ let paused = false
const setAssistantMessage = (message: ChatMessage) => {
assistantMessage = message
}
@@ -344,9 +497,16 @@ export const createMessageEngine = (
// 允许插件在 onTurnStart 钩子中修改 responseProvider
// 并在整个 turn 请求过程中防止因它发生变化而导致的不一致
const turnResponseProvider = runtime.responseProvider
+ runtime.currentTurnResponseProvider = turnResponseProvider
try {
await executeRequest(turnResponseProvider, ac.signal, { setAssistantMessage })
+
+ if (isAwaitingToolResults()) {
+ paused = true
+ return
+ }
+
setRequestState('completed')
} catch (error) {
// 检查是否是中止错误:优先检查当前使用的 AbortController 的信号状态
@@ -364,10 +524,7 @@ export const createMessageEngine = (
}
// 3) onTurnEnd 串行执行,有错误则中断
- const baseContextAtEnd = getBaseContext(ac.signal)
- for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, baseContextAtEnd))) {
- await plugin.onTurnEnd?.(baseContextAtEnd)
- }
+ await runTurnEnd(ac.signal)
} catch (error) {
setRequestState('error')
@@ -386,26 +543,9 @@ export const createMessageEngine = (
throw error
}
} finally {
- const context = getBaseContext(ac.signal)
- for (const plugin of plugins.filter((plugin) => !isPluginDisabled(plugin, context))) {
- try {
- plugin.onFinally?.(context)
- } catch (error) {
- console.error(`Error in onFinally hook for plugin [${plugin.name || 'Anonymous'}]:`, error)
- }
+ if (!paused) {
+ cleanupTurn(ac.signal, assistantMessage)
}
-
- runtime.abortController = null
- runtime.currentTurn = []
-
- // 如果请求立即出错,loading 可能一直为 true,这时需要手动将其置为 false
- mutate('messages', (_, skipNotify) => {
- if (assistantMessage?.loading) {
- assistantMessage.loading = undefined
- } else {
- skipNotify()
- }
- })
}
}
@@ -443,7 +583,62 @@ export const createMessageEngine = (
await runTurn()
}
+ async function submitToolResult(message: ChatMessage | ChatMessage[]) {
+ const state = getState()
+ if (state.requestState === 'processing' && state.processingState !== 'awaiting-tool-results') {
+ console.warn('Cannot submit tool result while processing is in progress')
+ return
+ }
+
+ const messages = Array.isArray(message) ? message : [message]
+ if (messages.some((item) => item.role !== 'tool')) {
+ console.warn('submitToolResult only accepts messages with role "tool"')
+ return
+ }
+
+ const pendingAssistant = findLastAssistantMessageWithToolCalls()
+ if (!pendingAssistant) {
+ console.warn('Cannot submit tool result without a pending assistant tool call')
+ return
+ }
+
+ const now = Math.floor(Date.now() / 1000)
+ appendMessages(
+ ...messages.map((item) => ({
+ ...item,
+ metadata: {
+ createdAt: now,
+ updatedAt: now,
+ ...item.metadata,
+ },
+ })),
+ )
+
+ const toolMessages = getToolMessagesAfter(pendingAssistant.index)
+ updateSubmittedToolCallStates(pendingAssistant.index, toolMessages)
+
+ if (allToolCallsHaveResults(pendingAssistant.message, toolMessages)) {
+ if (isAwaitingToolResults()) {
+ await continueTurn()
+ } else {
+ await runTurn()
+ }
+ } else {
+ setRequestState('processing', 'awaiting-tool-results')
+ }
+ }
+
async function abort() {
+ if (isAwaitingToolResults()) {
+ const ac = runtime.abortController ?? new AbortController()
+ runtime.abortController = ac
+ ac.abort()
+ setRequestState('aborted')
+ await runTurnEnd(ac.signal)
+ cleanupTurn(ac.signal, null)
+ return
+ }
+
runtime.abortController?.abort()
// 等待直到 isProcessing 变为 false
@@ -465,6 +660,7 @@ export const createMessageEngine = (
subscribe,
sendMessage,
send,
+ submitToolResult,
abort,
setResponseProvider(provider) {
runtime.responseProvider = provider
diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts
index b7efeef2c..ddb146961 100644
--- a/packages/kit/src/message/plugins/index.ts
+++ b/packages/kit/src/message/plugins/index.ts
@@ -1,3 +1,4 @@
export { lengthPlugin } from './lengthPlugin'
export { thinkingPlugin } from './thinkingPlugin'
export { toolPlugin } from './toolPlugin'
+export type { ToolCallDecision } from './toolPlugin'
diff --git a/packages/kit/src/message/plugins/toolPlugin.ts b/packages/kit/src/message/plugins/toolPlugin.ts
index 0748c1715..729cd2ab3 100644
--- a/packages/kit/src/message/plugins/toolPlugin.ts
+++ b/packages/kit/src/message/plugins/toolPlugin.ts
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources/index'
+import type { MaybePromise } from '../../types'
import type { BasePluginContext, ChatMessage, MessageEnginePlugin, MutateMessageStateFn } from '../types'
import { combineDeltaData, normalizeToAsyncGenerator } from '../utils'
@@ -13,6 +14,29 @@ type ToolCallContext = BasePluginContext & {
toolMessage: ChatMessage
}
+type ToolCallConfirmContext = BasePluginContext & {
+ assistantMessage: AssistantMessageWithState
+}
+
+export type ToolCallDecision =
+ | {
+ /**
+ * 允许当前工具调用继续执行。
+ *
+ * 后续可扩展的候选动作:
+ * - `{ action: 'allow_session' }`:允许当前会话中匹配的工具调用。
+ * - `{ action: 'respond'; message: string }`:跳过工具执行,并将引导信息返回给模型。
+ */
+ action: 'allow'
+ }
+ | {
+ /**
+ * 拒绝当前工具调用,并可将 message 作为工具结果返回给模型。
+ */
+ action: 'deny'
+ message?: string
+ }
+
/**
* 补全缺失的工具消息
* 遍历所有 messages,找到所有 role 为 assistant 并且 tool_calls 数组不为空的 message。
@@ -116,6 +140,22 @@ export const toolPlugin = (
toolCall: ChatCompletionMessageToolCall,
context: ToolCallContext,
) => Promise> | AsyncGenerator>
+ /**
+ * 判断当前工具调用是否需要外部确认。
+ *
+ * 传入该函数时,工具调用会停在 `awaiting-approval` 状态,不会创建空的 tool message,也不会执行 `callTool`。
+ * 业务侧确认后应调用 `submitToolResult` 补齐对应的 `role: 'tool'` 结果消息;当上一个 assistant message
+ * 的全部 `tool_calls` 都有结果后,kit 会继续下一轮请求。
+ *
+ * 未传入时默认不需要确认,会直接执行 `callTool`。返回值保留用于业务侧兼容,但不再控制是否暂停。
+ *
+ * TODO(v2): 当前只支持“本次允许/拒绝并提交结果”。后续可在消息状态和提交 API 上扩展
+ * allow_session、respond/tell ai how to do 等动作,并支持按会话持久化授权策略。
+ */
+ confirmToolCall?: (
+ toolCall: ChatCompletionMessageToolCall,
+ context: ToolCallConfirmContext,
+ ) => MaybePromise
/**
* 工具调用开始时的回调函数。
* 触发时机:工具消息已创建并追加后,调用 callTool 之前触发。
@@ -128,13 +168,13 @@ export const toolPlugin = (
* 触发时机:工具调用完成(成功、失败或取消)时触发。
* @param toolCall - 工具调用对象
* @param context - 插件上下文,包含当前工具消息、状态和错误信息
- * @param context.status - 工具调用状态:'success' | 'failed' | 'cancelled'
+ * @param context.status - 工具调用状态:'success' | 'failed' | 'cancelled' | 'denied'
* @param context.error - 当状态为 'failed' 或 'cancelled' 时,可能包含错误信息
*/
onToolCallEnd?: (
toolCall: ChatCompletionMessageToolCall,
context: ToolCallContext & {
- status: 'success' | 'failed' | 'cancelled'
+ status: 'success' | 'failed' | 'cancelled' | 'denied'
error?: Error
},
) => void
@@ -146,6 +186,10 @@ export const toolPlugin = (
* 当工具调用执行失败(抛错或拒绝)时使用的消息内容。
*/
toolCallFailedContent?: string
+ /**
+ * 当工具调用被拒绝时使用的默认消息内容。
+ */
+ toolCallDeniedContent?: string
/**
* 是否在请求前自动补充缺失的 tool 消息。
* 当 assistant 响应了 tool_calls 但未追加对应的 tool 消息时,
@@ -158,10 +202,12 @@ export const toolPlugin = (
getTools,
beforeCallTools,
callTool,
+ confirmToolCall,
onToolCallStart,
onToolCallEnd,
toolCallCancelledContent = 'Tool call cancelled.',
toolCallFailedContent = 'Tool call failed.',
+ toolCallDeniedContent: _toolCallDeniedContent = 'Tool call denied.',
autoFillMissingToolMessages = false,
...restOptions
} = options
@@ -186,6 +232,15 @@ export const toolPlugin = (
onToolCallStart?.(...args)
}
+ const toolCallAwaitApproval = (toolCall: ChatCompletionMessageToolCall, context: ToolCallConfirmContext) => {
+ const { assistantMessage, mutate } = context
+
+ mutate('messages', () => {
+ const message = ensureToolCallState(assistantMessage, toolCall.id)
+ message.state.toolCall[toolCall.id].status = 'awaiting-approval'
+ })
+ }
+
const toolCallEnd = (...args: Parameters>) => {
const [toolCall, { status, assistantMessage, mutate }] = args
@@ -237,35 +292,53 @@ export const toolPlugin = (
}
setRequestState('processing', 'calling-tools')
+ const assistantMessage = currentMessage as AssistantMessageWithState
+ const contextWithAssistant = {
+ ...context,
+ assistantMessage,
+ }
+
await beforeCallTools?.(currentMessage.tool_calls as ChatCompletionMessageToolCall[], {
...context,
- assistantMessage: currentMessage as AssistantMessageWithState,
+ assistantMessage,
})
const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => {
const now = Math.floor(Date.now() / 1000)
let hasMeaningfulResult = false
- const toolMessage: ChatMessage = createMessage({
- role: 'tool',
- tool_call_id: toolCall.id,
- content: '',
- metadata: {
- createdAt: now,
- updatedAt: now,
- },
- })
+ let toolMessage: ChatMessage | undefined
+ let contextWithToolMessage: ToolCallContext | undefined
- appendMessage(toolMessage)
+ try {
+ if (confirmToolCall) {
+ await Promise.resolve(confirmToolCall(toolCall, contextWithAssistant))
+ toolCallAwaitApproval(toolCall, contextWithAssistant)
+ return 'pending' as const
+ }
- const contextWithToolMessage = {
- ...context,
- assistantMessage: currentMessage as AssistantMessageWithState,
- toolMessage,
- }
+ toolMessage = createMessage({
+ role: 'tool',
+ tool_call_id: toolCall.id,
+ content: '',
+ metadata: {
+ createdAt: now,
+ updatedAt: now,
+ },
+ })
+
+ appendMessage(toolMessage)
+
+ contextWithToolMessage = {
+ ...context,
+ assistantMessage,
+ toolMessage,
+ }
+ const activeToolMessage = toolMessage
+ const activeContextWithToolMessage = contextWithToolMessage
- toolCallStart(toolCall, contextWithToolMessage)
- try {
- const result = callTool(toolCall, contextWithToolMessage)
+ toolCallStart(toolCall, activeContextWithToolMessage)
+
+ const result = callTool(toolCall, activeContextWithToolMessage)
// 将 Promise 或异步迭代器统一转换为异步生成器
const iterator = normalizeToAsyncGenerator(result)
@@ -282,34 +355,52 @@ export const toolPlugin = (
// 字符串拼接或 JSON 合并
if (typeof chunk === 'string') {
- toolMessage.content += chunk
+ activeToolMessage.content += chunk
} else {
let parsedContent: Record = {}
try {
- const content = Array.isArray(toolMessage.content)
- ? toolMessage.content.map((item) => item.text).join('')
- : toolMessage.content
+ const content = Array.isArray(activeToolMessage.content)
+ ? activeToolMessage.content.map((item: any) => item.text).join('')
+ : activeToolMessage.content
parsedContent = JSON.parse(content || '{}')
} catch (error) {
console.warn(error)
}
- toolMessage.content = JSON.stringify(combineDeltaData(parsedContent, chunk))
+ activeToolMessage.content = JSON.stringify(combineDeltaData(parsedContent, chunk))
}
- toolMessage.metadata!.updatedAt = Math.floor(Date.now() / 1000)
+ activeToolMessage.metadata!.updatedAt = Math.floor(Date.now() / 1000)
})
}
- toolCallEnd(toolCall, { ...contextWithToolMessage, status: 'success' })
+ toolCallEnd(toolCall, { ...activeContextWithToolMessage, status: 'success' })
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
+ if (!toolMessage || !contextWithToolMessage) {
+ console.error(error)
+ mutate('messages', () => {
+ const message = ensureToolCallState(assistantMessage, toolCall.id)
+ message.state.toolCall[toolCall.id].status = 'failed'
+ })
+ return 'handled' as const
+ }
+ const activeToolMessage = toolMessage
+ const activeContextWithToolMessage = contextWithToolMessage
+
// 如果被 abort ,则抛出错误,主流程会处理状态
// 也可以不抛出错误,直接返回,主流程会自动处理 abort 场景
if (abortSignal.aborted) {
- toolCallEnd(toolCall, { ...contextWithToolMessage, status: 'cancelled', error: err })
+ if (!hasMeaningfulResult) {
+ mutate('messages', () => {
+ activeToolMessage.content = toolCallCancelledContent
+ activeToolMessage.metadata!.updatedAt = Math.floor(Date.now() / 1000)
+ })
+ }
+
+ toolCallEnd(toolCall, { ...activeContextWithToolMessage, status: 'cancelled', error: err })
// throw error
- return
+ return 'handled' as const
}
// 其他错误视为工具调用失败,则将工具消息内容设置为失败内容
@@ -317,17 +408,25 @@ export const toolPlugin = (
if (!hasMeaningfulResult) {
mutate('messages', () => {
- toolMessage.content = toolCallFailedContent
- toolMessage.metadata!.updatedAt = Math.floor(Date.now() / 1000)
+ activeToolMessage.content = toolCallFailedContent
+ activeToolMessage.metadata!.updatedAt = Math.floor(Date.now() / 1000)
})
}
- toolCallEnd(toolCall, { ...contextWithToolMessage, status: 'failed', error: err })
+ toolCallEnd(toolCall, { ...activeContextWithToolMessage, status: 'failed', error: err })
}
+
+ return 'handled' as const
})
- await Promise.all(toolCallPromises)
- if (!abortSignal.aborted) {
+ const toolCallResults = await Promise.all(toolCallPromises)
+ if (abortSignal.aborted) {
+ return restOptions.onAfterRequest?.(context)
+ }
+
+ if (toolCallResults.some((result) => result === 'pending')) {
+ setRequestState('processing', 'awaiting-tool-results')
+ } else {
requestNext()
}
diff --git a/packages/kit/src/message/types.ts b/packages/kit/src/message/types.ts
index 6ef9a9baa..2921ee81f 100644
--- a/packages/kit/src/message/types.ts
+++ b/packages/kit/src/message/types.ts
@@ -17,7 +17,7 @@ export type DeepReadonly = T extends (...args: any[]) => any
// Define different states for the request process
export type RequestState = 'idle' | 'processing' | 'completed' | 'aborted' | 'error'
-export type RequestProcessingState = 'requesting' | 'completing' | string
+export type RequestProcessingState = 'requesting' | 'completing' | 'calling-tools' | 'awaiting-tool-results' | string
export type ChatMessage<
Metadata extends object = Record,
@@ -57,6 +57,7 @@ export interface MessageRuntime {
currentTurn: ChatMessage[]
customContext: Record
abortController: AbortController | null
+ currentTurnResponseProvider: ResponseProvider | null
responseProvider: ResponseProvider
}
@@ -66,6 +67,7 @@ export interface MessageEngine {
subscribe(kinds: MessageUpdateKinds, listener: (state: PublicMessageState) => void): () => void
sendMessage(content: string): Promise
send(...msgs: ChatMessage[]): Promise
+ submitToolResult(message: ChatMessage | ChatMessage[]): Promise
abort(): Promise
setResponseProvider(provider: ResponseProvider): void
}
diff --git a/packages/kit/src/vue/message/plugins/toolPlugin.ts b/packages/kit/src/vue/message/plugins/toolPlugin.ts
index 564bfb62e..4be8e6d06 100644
--- a/packages/kit/src/vue/message/plugins/toolPlugin.ts
+++ b/packages/kit/src/vue/message/plugins/toolPlugin.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { toolPlugin as createCoreToolPlugin } from '../../../message/plugins'
import { normalizeToAsyncGenerator } from '../../../message/utils'
-import { ChatMessage, ToolCall } from '../../../types'
+import { ChatMessage, MaybePromise, ToolCall } from '../../../types'
import type { VueMessagePluginRuntime } from '../types.internal'
import { BasePluginContext, Tool, UseMessagePlugin } from '../types'
@@ -43,6 +43,14 @@ export const toolPlugin = (
toolCall: ToolCall,
context: UseMessageCallToolContext,
) => Promise> | AsyncGenerator>
+ /**
+ * 判断当前工具调用是否需要外部确认。
+ *
+ * 传入该函数时,工具调用会停在 `awaiting-approval` 状态。业务侧确认后应调用
+ * `submitToolResult` 提交对应的 `role: 'tool'` 结果消息。返回值保留用于业务侧兼容,
+ * 不再控制是否暂停。
+ */
+ confirmToolCall?: (toolCall: ToolCall, context: UseMessageToolActionContext) => MaybePromise
/**
* 工具调用开始时的回调函数。
* 触发时机:工具消息已创建并追加后,调用 callTool 之前触发。
@@ -55,13 +63,13 @@ export const toolPlugin = (
* 触发时机:工具调用完成(成功、失败或取消)时触发。
* @param toolCall - 工具调用对象
* @param context - 插件上下文,包含当前工具消息、状态和错误信息
- * @param context.status - 工具调用状态:'success' | 'failed' | 'cancelled'
+ * @param context.status - 工具调用状态:'success' | 'failed' | 'cancelled' | 'denied'
* @param context.error - 当状态为 'failed' 或 'cancelled' 时,可能包含错误信息
*/
onToolCallEnd?: (
toolCall: ToolCall,
context: UseMessageToolCallContext & {
- status: 'success' | 'failed' | 'cancelled'
+ status: 'success' | 'failed' | 'cancelled' | 'denied'
error?: Error
},
) => void
@@ -73,6 +81,10 @@ export const toolPlugin = (
* 当工具调用执行失败(抛错或拒绝)时使用的消息内容。
*/
toolCallFailedContent?: string
+ /**
+ * 当工具调用被拒绝时使用的默认消息内容。
+ */
+ toolCallDeniedContent?: string
/**
* 是否在请求前自动补充缺失的 tool 消息。
* 当 assistant 响应了 tool_calls 但未追加对应的 tool 消息时,
@@ -85,10 +97,12 @@ export const toolPlugin = (
getTools,
beforeCallTools,
callTool,
+ confirmToolCall,
onToolCallStart,
onToolCallEnd,
toolCallCancelledContent = 'Tool call cancelled.',
toolCallFailedContent = 'Tool call failed.',
+ toolCallDeniedContent = 'Tool call denied.',
autoFillMissingToolMessages = false,
...restOptions
} = options
@@ -130,6 +144,17 @@ export const toolPlugin = (
yield chunk
}
},
+ confirmToolCall: confirmToolCall
+ ? (toolCall, context) => {
+ const assistantMessage = runtime.resolveReactiveMessage(context.assistantMessage as ChatMessage)
+
+ return confirmToolCall(toolCall as unknown as ToolCall, {
+ ...runtime.createVueBaseContext(context),
+ assistantMessage,
+ currentMessage: assistantMessage,
+ })
+ }
+ : undefined,
onToolCallStart: onToolCallStart
? (toolCall, context) => {
const assistantMessage = runtime.resolveReactiveMessage(context.assistantMessage as ChatMessage)
@@ -160,6 +185,7 @@ export const toolPlugin = (
: undefined,
toolCallCancelledContent,
toolCallFailedContent,
+ toolCallDeniedContent,
autoFillMissingToolMessages,
})
},
diff --git a/packages/kit/src/vue/message/types.ts b/packages/kit/src/vue/message/types.ts
index 8f93ae222..f70b6bc60 100644
--- a/packages/kit/src/vue/message/types.ts
+++ b/packages/kit/src/vue/message/types.ts
@@ -24,7 +24,7 @@ export interface MessageRequestBody {
// Define different states for the request process
export type RequestState = 'idle' | 'processing' | 'completed' | 'aborted' | 'error'
-export type RequestProcessingState = 'requesting' | 'completing' | string
+export type RequestProcessingState = 'requesting' | 'completing' | 'calling-tools' | 'awaiting-tool-results' | string
// Usage information for API response
export interface Usage {
@@ -119,6 +119,7 @@ export interface UseMessageReturn {
isProcessing: ComputedRef
sendMessage: (content: string) => Promise
send: (...msgs: ChatMessage[]) => Promise
+ submitToolResult: (message: ChatMessage | ChatMessage[]) => Promise
abortRequest: () => Promise
}
diff --git a/packages/kit/src/vue/message/useMessage.test.ts b/packages/kit/src/vue/message/useMessage.test.ts
index 4a05871ba..534b4f008 100644
--- a/packages/kit/src/vue/message/useMessage.test.ts
+++ b/packages/kit/src/vue/message/useMessage.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '../../types'
import { mockResponseProvider, mockSequentialResponseProvider } from './mockResponseProvider'
import { lengthPlugin } from './plugins/lengthPlugin'
@@ -6,6 +6,18 @@ import { toolPlugin } from './plugins/toolPlugin'
import type { ResponseProvider } from './types'
import { useMessage } from './useMessage'
+const waitFor = async (condition: () => boolean, timeout = 1000) => {
+ const startedAt = Date.now()
+
+ while (!condition()) {
+ if (Date.now() - startedAt > timeout) {
+ throw new Error('Timed out waiting for condition')
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+}
+
describe('useMessage', () => {
it('uses the core vue adapter while keeping the original return shape', async () => {
const engine = useMessage({
@@ -191,4 +203,256 @@ describe('useMessage', () => {
content: 'done',
})
})
+
+ it('waits for submitted tool results before continuing confirmed tool calls', async () => {
+ const callTool = vi.fn()
+ const secondRequestToolMessages: ChatMessage[] = []
+ let requestCount = 0
+
+ const responseProvider = mockSequentialResponseProvider([
+ {
+ finish_reason: 'tool_calls',
+ content: '',
+ tool_calls: [
+ {
+ index: 0,
+ id: 'call-allow',
+ type: 'function',
+ function: {
+ name: 'lookup',
+ arguments: '{}',
+ },
+ },
+ {
+ index: 1,
+ id: 'call-deny',
+ type: 'function',
+ function: {
+ name: 'delete',
+ arguments: '{}',
+ },
+ },
+ ],
+ },
+ {
+ content: 'done',
+ onRequest(requestBody) {
+ requestCount += 1
+ secondRequestToolMessages.push(
+ ...(requestBody.messages.filter((message) => message.role === 'tool') as ChatMessage[]),
+ )
+ },
+ },
+ ])
+
+ const engine = useMessage({
+ responseProvider,
+ plugins: [
+ toolPlugin({
+ async getTools() {
+ return [
+ {
+ type: 'function',
+ function: {
+ name: 'lookup',
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'delete',
+ },
+ },
+ ]
+ },
+ confirmToolCall(toolCall, context) {
+ expect(context.assistantMessage.tool_calls?.some((item) => item.id === toolCall.id)).toBe(true)
+ return true
+ },
+ callTool,
+ }),
+ ],
+ })
+
+ await engine.sendMessage('ping')
+
+ await waitFor(() => engine.messages.value[1]?.state?.toolCall?.['call-allow']?.status === 'awaiting-approval')
+ expect(engine.requestState.value).toBe('processing')
+ expect(engine.processingState.value).toBe('awaiting-tool-results')
+ expect(engine.isProcessing.value).toBe(true)
+ expect(engine.messages.value[1]).toMatchObject({
+ role: 'assistant',
+ state: {
+ toolCall: {
+ 'call-allow': { status: 'awaiting-approval' },
+ 'call-deny': { status: 'awaiting-approval' },
+ },
+ },
+ })
+ expect(callTool).not.toHaveBeenCalled()
+ expect(engine.messages.value).toHaveLength(2)
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ await engine.sendMessage('should be blocked while awaiting tool results')
+ expect(warnSpy).toHaveBeenCalledWith('Cannot send message while processing is in progress')
+ warnSpy.mockRestore()
+ expect(engine.messages.value).toHaveLength(2)
+
+ await engine.submitToolResult({
+ role: 'tool',
+ tool_call_id: 'call-allow',
+ content: 'result:call-allow',
+ metadata: { toolCallStatus: 'success' },
+ })
+
+ expect(requestCount).toBe(0)
+ expect(engine.requestState.value).toBe('processing')
+ expect(engine.processingState.value).toBe('awaiting-tool-results')
+ expect(engine.messages.value[1]).toMatchObject({
+ role: 'assistant',
+ state: {
+ toolCall: {
+ 'call-allow': { status: 'success' },
+ 'call-deny': { status: 'awaiting-approval' },
+ },
+ },
+ })
+
+ await engine.submitToolResult({
+ role: 'tool',
+ tool_call_id: 'call-deny',
+ content: 'Tool call denied.',
+ metadata: { toolCallStatus: 'denied' },
+ })
+
+ expect(requestCount).toBe(1)
+ expect(engine.requestState.value).toBe('completed')
+ expect(engine.processingState.value).toBeUndefined()
+ expect(engine.isProcessing.value).toBe(false)
+ expect(secondRequestToolMessages).toMatchObject([
+ {
+ role: 'tool',
+ tool_call_id: 'call-allow',
+ content: 'result:call-allow',
+ },
+ {
+ role: 'tool',
+ tool_call_id: 'call-deny',
+ content: 'Tool call denied.',
+ },
+ ])
+ expect(engine.messages.value[1]).toMatchObject({
+ role: 'assistant',
+ state: {
+ toolCall: {
+ 'call-allow': { status: 'success' },
+ 'call-deny': { status: 'denied' },
+ },
+ },
+ })
+ expect(engine.messages.value.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: 'done',
+ })
+ })
+
+ it('continues after submitToolResult receives every tool call result at once', async () => {
+ const secondRequestToolMessages: ChatMessage[] = []
+ let requestCount = 0
+
+ const responseProvider = mockSequentialResponseProvider([
+ {
+ finish_reason: 'tool_calls',
+ content: '',
+ tool_calls: [
+ {
+ index: 0,
+ id: 'call-a',
+ type: 'function',
+ function: {
+ name: 'lookup',
+ arguments: '{}',
+ },
+ },
+ {
+ index: 1,
+ id: 'call-b',
+ type: 'function',
+ function: {
+ name: 'lookup',
+ arguments: '{}',
+ },
+ },
+ ],
+ },
+ {
+ content: 'done',
+ onRequest(requestBody) {
+ requestCount += 1
+ secondRequestToolMessages.push(
+ ...(requestBody.messages.filter((message) => message.role === 'tool') as ChatMessage[]),
+ )
+ },
+ },
+ ])
+
+ const engine = useMessage({
+ responseProvider,
+ plugins: [
+ toolPlugin({
+ async getTools() {
+ return [
+ {
+ type: 'function',
+ function: {
+ name: 'lookup',
+ },
+ },
+ ]
+ },
+ confirmToolCall() {
+ return true
+ },
+ async callTool() {
+ return 'unused'
+ },
+ }),
+ ],
+ })
+
+ await engine.sendMessage('ping')
+
+ await waitFor(() => engine.messages.value[1]?.state?.toolCall?.['call-a']?.status === 'awaiting-approval')
+ await engine.submitToolResult([
+ {
+ role: 'tool',
+ tool_call_id: 'call-a',
+ content: 'result:a',
+ },
+ {
+ role: 'tool',
+ tool_call_id: 'call-b',
+ content: 'result:b',
+ },
+ ])
+
+ expect(requestCount).toBe(1)
+ expect(secondRequestToolMessages).toMatchObject([
+ { role: 'tool', tool_call_id: 'call-a', content: 'result:a' },
+ { role: 'tool', tool_call_id: 'call-b', content: 'result:b' },
+ ])
+ expect(engine.messages.value[1]).toMatchObject({
+ role: 'assistant',
+ state: {
+ toolCall: {
+ 'call-a': { status: 'success' },
+ 'call-b': { status: 'success' },
+ },
+ },
+ })
+ expect(engine.messages.value.at(-1)).toMatchObject({
+ role: 'assistant',
+ content: 'done',
+ })
+ })
})
diff --git a/packages/kit/src/vue/message/useMessage.ts b/packages/kit/src/vue/message/useMessage.ts
index cbbcb947b..0ed2561c9 100644
--- a/packages/kit/src/vue/message/useMessage.ts
+++ b/packages/kit/src/vue/message/useMessage.ts
@@ -186,6 +186,7 @@ export const useMessage = (options: UseMessageOptions): UseMessageReturn => {
isProcessing: adapter.isProcessing,
sendMessage: engine.sendMessage,
send: engine.send,
+ submitToolResult: engine.submitToolResult,
abortRequest: engine.abort,
} as UseMessageReturn
}