diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d5975260b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +## 当前主线任务 + +当前正在完善 `useMessage` 的工具调用确认流程,核心目标是把工具调用从“运行时 Promise 等待确认”改为“通过可序列化的消息状态和 `role: 'tool'` 结果消息继续流程”。 + +这条线的后续重点是:给 `useMessage` 增加一个等待工具结果提交的 processing 状态,让工具确认流程可以被存储、恢复,并支持 pause/resume。 + +## 已完成内容 + +- 新增 `submitToolResult(message | message[])` API。 + - core:`packages/kit/src/message/core/engine.ts` + - 类型:`packages/kit/src/message/types.ts` + - Vue 返回值:`packages/kit/src/vue/message/useMessage.ts` + - Vue 类型:`packages/kit/src/vue/message/types.ts` +- `submitToolResult` 只接受 `role: 'tool'` 消息。 +- 提交 tool result 后,会检查最近一个 assistant message 的 `tool_calls` 是否都已有结果。 +- 全部 tool result 齐全后,kit 会自动继续下一轮请求。 +- `toolPlugin.confirmToolCall` 已改为布尔判断: + - 返回 `true`:标记工具调用为 `awaiting-approval`,不创建空 tool message,不执行 `callTool`。 + - 返回 `false` 或未传:按原自动工具调用逻辑执行 `callTool`。 +- Vue 侧 `toolPlugin` 已同步简化,不再包装旧的 `ToolCallDecision` Promise 等待逻辑。 +- Tool 渲染器已支持 `awaiting-approval` 状态下展示“允许 / 拒绝”按钮。 +- 按钮点击通过 Bubble 的通用 `state-change` 事件发出 `toolCallDecision`,业务侧再调用 `submitToolResult`。 +- 文档 demo 已抽成组件: + - `docs/demos/tools/message/ToolCallConfirm.ts` + - `docs/demos/tools/message/ToolCallConfirm.vue` +- 已新增后续设计 TODO: + - `packages/kit/TODO.md` + +## 后续待做 + +主要 TODO 在 `packages/kit/TODO.md`。 + +下一步建议实现: + +1. 扩展 `RequestProcessingState`,增加 `awaiting-tool-results`。 +2. 当 `toolPlugin` 发现有工具调用需要确认时: + - 设置对应 `state.toolCall[id].status = 'awaiting-approval'`。 + - 设置全局 `processingState = 'awaiting-tool-results'`。 + - 停止本轮自动 `requestNext`。 +3. `submitToolResult` 提交部分 tool result 后: + - 更新对应 tool call 状态。 + - 如果仍有缺失结果,保持 `awaiting-tool-results`。 + - 如果全部结果齐全,进入下一轮请求。 +4. 恢复会话时,根据最近一个 assistant message 的 `tool_calls` 和后续 tool messages 差集推导是否仍在等待工具结果。 +5. UI 可根据 `processingState === 'awaiting-tool-results'` 展示全局等待状态。 + +## 关键设计约束 + +- 不要恢复旧的 `await confirmToolCall` 内存等待方案。页面刷新后 Promise/resolver 无法恢复。 +- deny 也应该是一条 `role: 'tool'` 消息,通过 `submitToolResult` 提交。 +- 多个并行 tool call 可以部分提交,但只有全部结果齐全后才能继续请求。 +- `submitToolResult` 应只针对最近一个带 `tool_calls` 的 assistant message 生效。 +- 如果最近一个 assistant message 没有 `tool_calls`,提交 tool result 应拒绝或警告。 +- 不要通过监听变量触发工具调用,优先使用 Bubble 现有 `state-change` 事件机制。 +- 文档构建很慢,除非明确要求,不要运行 docs build。 + +## 常用验证命令 + +```bash +pnpm -F @opentiny/tiny-robot-kit test -- src/vue/message/useMessage.test.ts +pnpm -F @opentiny/tiny-robot-kit build +``` + +当前相关单测已覆盖: + +- 等待外部提交 tool result 后再继续请求。 +- 并行 tool calls 支持部分提交,全部提交后再继续。 +- Vue 侧 toolPlugin 仍能正常走自动工具调用流程。 + +## 相关文件 + +- `packages/kit/src/message/core/engine.ts` +- `packages/kit/src/message/plugins/toolPlugin.ts` +- `packages/kit/src/vue/message/plugins/toolPlugin.ts` +- `packages/kit/src/vue/message/useMessage.ts` +- `packages/kit/src/vue/message/useMessage.test.ts` +- `packages/components/src/bubble/renderers/Tool.vue` +- `packages/components/src/bubble/composables/useToolCall.ts` +- `docs/demos/tools/message/ToolCallConfirm.ts` +- `docs/demos/tools/message/ToolCallConfirm.vue` +- `docs/src/tools/message.md` +- `packages/kit/TODO.md` + +## 工作区注意事项 + +当前工作区可能包含与这条任务无关的已有改动,例如: + +- `.gitignore` +- `docs/.vitepress/config.mts` +- `packages/playground/vite.config.ts` + +继续工作时不要随意 revert 这些文件,除非明确知道它们属于当前任务并且需要处理。 diff --git a/docs/demos/bubble/tool-choice.vue b/docs/demos/bubble/tool-choice.vue new file mode 100644 index 000000000..66a064c39 --- /dev/null +++ b/docs/demos/bubble/tool-choice.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/docs/demos/tools/message/ToolCallConfirm.ts b/docs/demos/tools/message/ToolCallConfirm.ts new file mode 100644 index 000000000..380a20a60 --- /dev/null +++ b/docs/demos/tools/message/ToolCallConfirm.ts @@ -0,0 +1,107 @@ +import type { ChatCompletion, MessageRequestBody, Tool } from '@opentiny/tiny-robot-kit' +import { toolPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +const getTools = async (): Promise => [ + { + type: 'function', + function: { + name: 'search_private_docs', + description: '搜索内部文档。', + parameters: { + type: 'object', + properties: { keyword: { type: 'string' } }, + required: ['keyword'], + }, + }, + }, +] + +export function useMessageToolCallConfirm() { + return useMessage({ + responseProvider: mockStreamWithConfirmTools, + plugins: [ + toolPlugin({ + getTools, + confirmToolCall() { + return true + }, + callTool: async (toolCall) => { + const args = JSON.parse(toolCall.function?.arguments || '{}') + return `已搜索内部文档:${args.keyword}` + }, + }), + ], + initialMessages: [ + { + content: '发送任意消息后,示例会模拟一次需要确认的工具调用。', + role: 'assistant', + }, + ], + }) +} + +async function* mockStreamWithConfirmTools( + requestBody: MessageRequestBody, + abortSignal: AbortSignal, +): AsyncGenerator { + const msgs = requestBody.messages || [] + const last = msgs[msgs.length - 1] + const id = 'mock-confirm-tool-' + Date.now() + + if (last?.role === 'tool') { + const text = '工具调用已处理完成。' + for (let i = 0; i < text.length && !abortSignal.aborted; i++) { + await new Promise((resolve) => setTimeout(resolve, 40)) + const content = text[i] + yield { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: undefined, + delta: i === 0 ? { role: 'assistant', content } : { content }, + finish_reason: i === text.length - 1 ? 'stop' : null, + logprobs: null, + }, + ], + } + } + return + } + + await new Promise((resolve) => setTimeout(resolve, 300)) + yield { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: undefined, + delta: { + role: 'assistant', + content: '这个操作需要确认后再执行。', + tool_calls: [ + { + index: 0, + id: 'call_confirm_search_1', + type: 'function', + function: { + name: 'search_private_docs', + arguments: '{"keyword":"Q3 roadmap"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + } +} diff --git a/docs/demos/tools/message/ToolCallConfirm.vue b/docs/demos/tools/message/ToolCallConfirm.vue new file mode 100644 index 000000000..857d25a28 --- /dev/null +++ b/docs/demos/tools/message/ToolCallConfirm.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/docs/src/components/bubble.md b/docs/src/components/bubble.md index a8c7d756c..5a4d18347 100644 --- a/docs/src/components/bubble.md +++ b/docs/src/components/bubble.md @@ -264,6 +264,8 @@ Bubble 组件采用渲染器架构,支持灵活的内容渲染和自定义扩 + + #### 实现自定义渲染器 **Content 渲染器** diff --git a/docs/src/tools/message.md b/docs/src/tools/message.md index 1b4c14a4e..a6e0b023b 100644 --- a/docs/src/tools/message.md +++ b/docs/src/tools/message.md @@ -302,6 +302,15 @@ useMessage({ }) ``` +##### 工具调用确认示例 + +当工具调用需要用户确认时,可以使用 `confirmToolCall` 将单个工具调用标记为等待确认。内置 Tool 渲染器会在 +`state.toolCall[toolCallId].status === 'awaiting-approval'` 时展示“允许 / 拒绝”按钮;按钮点击后通过 +Bubble 的 `state-change` 事件触发 `toolCallDecision`,业务侧执行或拒绝工具调用,并通过 `submitToolResult` +提交对应的 `role: 'tool'` 结果消息。上一个 assistant message 的全部 `tool_calls` 都有结果后,kit 会继续发起下一轮请求。 + + + ##### 搭配 MCP 服务 `toolPlugin` 可以搭配 MCP(Model Context Protocol)服务使用,扩展 AI 的工具调用能力。以下示例展示如何接入高德地图 MCP 服务。 diff --git a/packages/components/src/bubble/composables/useToolCall.ts b/packages/components/src/bubble/composables/useToolCall.ts index 98389126b..48da1de84 100644 --- a/packages/components/src/bubble/composables/useToolCall.ts +++ b/packages/components/src/bubble/composables/useToolCall.ts @@ -3,7 +3,7 @@ import { BubbleContentRendererProps, ChatMessageContent } from '../index.type' import { getJsonrepair } from '../utils' import { useBubbleStore } from './useBubbleStore' -const toolCallStatus = ['running', 'success', 'failed', 'cancelled'] as const +const toolCallStatus = ['awaiting-approval', 'running', 'success', 'failed', 'cancelled', 'denied'] as const export type ToolCallStatus = (typeof toolCallStatus)[number] export const useToolCall = ( diff --git a/packages/components/src/bubble/renderers/Tool.vue b/packages/components/src/bubble/renderers/Tool.vue index cf3c99fef..03ea85804 100644 --- a/packages/components/src/bubble/renderers/Tool.vue +++ b/packages/components/src/bubble/renderers/Tool.vue @@ -15,17 +15,33 @@ const props = defineProps< const { toolCall, toolCallWithResult, state } = useToolCall(props) -const textAndIconMap = new Map([ - ['running', { text: '正在调用', icon: IconLoading }], - ['success', { text: '已调用', icon: IconPlugin }], - ['failed', { text: '调用失败', icon: IconError }], - ['cancelled', { text: '已取消', icon: IconCancelled }], +const textAndIconMap = new Map< + string, + { prefixText: string; suffixText?: string; allowText?: string; denyText?: string; icon: Component } +>([ + [ + 'awaiting-approval', + { + prefixText: '即将调用', + suffixText: '工具,请问是否同意?', + allowText: '同意', + denyText: '拒绝', + icon: IconPlugin, + }, + ], + ['running', { prefixText: '正在调用', icon: IconLoading }], + ['success', { prefixText: '已调用', icon: IconPlugin }], + ['failed', { prefixText: '调用失败', icon: IconError }], + ['cancelled', { prefixText: '已取消', icon: IconCancelled }], + ['denied', { prefixText: '已拒绝', icon: IconCancelled }], ]) const textAndIcon = computed(() => { - return textAndIconMap.get(state.value?.status || '') || { text: '', icon: IconPlugin } + return textAndIconMap.get(state.value?.status || '') || { prefixText: '', icon: IconPlugin } }) +const isAwaitingApproval = computed(() => state.value.status === 'awaiting-approval' && !approvalSubmitted.value) + const prettyJSON = (json: unknown, space = 2) => { let prettyJson = '' @@ -86,6 +102,7 @@ const detail = computed(() => { }) const detailRef = ref(null) +const approvalSubmitted = ref(false) watch(jsonStr, (_, oldValue) => { if (oldValue === '' || oldValue === '{}') { @@ -108,6 +125,10 @@ const open = ref(false) watchEffect(() => { open.value = state.value.open ?? false + + if (state.value.status !== 'awaiting-approval') { + approvalSubmitted.value = false + } }) const handleStateChange = useBubbleStateChangeFn() @@ -123,22 +144,44 @@ const handleClick = () => { }) } } + +const handleDecision = (action: 'allow' | 'deny') => { + const toolCallId = toolCall.value?.id + if (!toolCallId) { + return + } + + approvalSubmitted.value = true + handleStateChange('toolCallDecision', { + toolCallId, + decision: { action }, + }) +}