|
| 1 | +# 3.45 Agent ask_user 人机协作工具 |
| 2 | + |
| 3 | +## 概述 |
| 4 | + |
| 5 | +v3.1.4 新增 `ask_user` 工具,使 AI Agent 在遇到无法独立决策的问题时能暂停执行并向用户提问。用户通过前端 UI 选择 AI 生成的建议选项或自由输入,回答后 Agent 继续推理执行。 |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 问题 |
| 10 | + |
| 11 | +AI Agent 在多步任务中遇到超出自身能力的决策点时(如 Docker 镜像拉取失败、多种可行方案、费用确认),缺乏向用户求助的机制。导致: |
| 12 | +- 反复重试浪费 token |
| 13 | +- 做出次优或错误决策 |
| 14 | +- 用户被迫停止 Agent 手动介入 |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## 核心设计约束 |
| 19 | + |
| 20 | +1. **选项由 AI 生成**:`question`、`choices` 全部是工具参数,前端仅负责渲染,不做任何静态选项生成 |
| 21 | +2. **Agent loop 暂停等待**:`ask_user` 不走 MCP `ExecuteTool()`,在 `runAgentLoop` 中特殊拦截 |
| 22 | +3. **用户可选也可输入**:提供 AI 建议的选项按钮 + 自由输入框 |
| 23 | + |
| 24 | +--- |
| 25 | + |
| 26 | +## 数据流 |
| 27 | + |
| 28 | +``` |
| 29 | +AI Agent 调用 ask_user(question, choices, allow_freeform) |
| 30 | + → runAgentLoop 检测到 tool name == "ask_user",不走 MCP |
| 31 | + → 创建 chan string |
| 32 | + → emitEvent("ai-agent-ask-user", {conversationId, toolCallId, question, choices, allowFreeform}) |
| 33 | + → 前端 EventsOn 收到,渲染选项卡片 + 输入框 |
| 34 | + → 用户点击选项 or 输入文本 |
| 35 | + → 前端调 SubmitAskUserResponse(conversationId, answer) |
| 36 | + → 后端 channel 收到 answer |
| 37 | + → runAgentLoop 将 answer 作为 tool result 写入 aiMessages |
| 38 | + → 下一轮 AI 推理继续 |
| 39 | +``` |
| 40 | + |
| 41 | +--- |
| 42 | + |
| 43 | +## 后端实现 |
| 44 | + |
| 45 | +### 1. 通道管理 |
| 46 | + |
| 47 | +**文件**: `app_ai_chat.go` |
| 48 | + |
| 49 | +```go |
| 50 | +// 存储等待用户回答的 channel,key 为 conversationId |
| 51 | +var askUserChannels = struct { |
| 52 | + sync.Mutex |
| 53 | + m map[string]chan string |
| 54 | +}{m: make(map[string]chan string)} |
| 55 | +``` |
| 56 | + |
| 57 | +### 2. 用户回答接收 |
| 58 | + |
| 59 | +**文件**: `app_ai_chat.go` |
| 60 | + |
| 61 | +```go |
| 62 | +func (a *App) SubmitAskUserResponse(conversationId string, answer string) { |
| 63 | + askUserChannels.Lock() |
| 64 | + ch, ok := askUserChannels.m[conversationId] |
| 65 | + askUserChannels.Unlock() |
| 66 | + if ok { |
| 67 | + select { |
| 68 | + case ch <- answer: |
| 69 | + default: |
| 70 | + } |
| 71 | + } |
| 72 | +} |
| 73 | +``` |
| 74 | + |
| 75 | +此方法暴露为 Wails 绑定,前端可直接调用。 |
| 76 | + |
| 77 | +### 3. runAgentLoop 特殊拦截 |
| 78 | + |
| 79 | +**文件**: `app_ai_chat.go`,在 tool call 执行循环中: |
| 80 | + |
| 81 | +```go |
| 82 | +} else if tc.Function.Name == "ask_user" { |
| 83 | + question, _ := args["question"].(string) |
| 84 | + // 解析 choices 数组 |
| 85 | + var choices []string |
| 86 | + if rawChoices, ok := args["choices"].([]interface{}); ok { |
| 87 | + for _, c := range rawChoices { |
| 88 | + if s, ok := c.(string); ok { |
| 89 | + choices = append(choices, s) |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + // 解析 allow_freeform(默认 true) |
| 94 | + allowFreeform := true |
| 95 | + if af, ok := args["allow_freeform"].(bool); ok { |
| 96 | + allowFreeform = af |
| 97 | + } |
| 98 | + |
| 99 | + // 创建 channel 并注册 |
| 100 | + ch := make(chan string, 1) |
| 101 | + askUserChannels.Lock() |
| 102 | + askUserChannels.m[conversationId] = ch |
| 103 | + askUserChannels.Unlock() |
| 104 | + defer func() { |
| 105 | + askUserChannels.Lock() |
| 106 | + delete(askUserChannels.m, conversationId) |
| 107 | + askUserChannels.Unlock() |
| 108 | + }() |
| 109 | + |
| 110 | + // 发送事件到前端 |
| 111 | + a.emitEvent("ai-agent-ask-user", map[string]interface{}{ |
| 112 | + "conversationId": conversationId, |
| 113 | + "toolCallId": tc.ID, |
| 114 | + "question": question, |
| 115 | + "choices": choices, |
| 116 | + "allowFreeform": allowFreeform, |
| 117 | + }) |
| 118 | + |
| 119 | + // 阻塞等待用户回答或取消 |
| 120 | + select { |
| 121 | + case answer := <-ch: |
| 122 | + resultContent = answer |
| 123 | + success = true |
| 124 | + case <-ctx.Done(): |
| 125 | + resultContent = "用户未回答,操作已取消" |
| 126 | + success = false |
| 127 | + } |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +### 4. 工具定义 |
| 132 | + |
| 133 | +**文件**: `mod/mcp/mcp.go` |
| 134 | + |
| 135 | +工具参数: |
| 136 | +| 参数 | 类型 | 必填 | 说明 | |
| 137 | +|------|------|------|------| |
| 138 | +| question | string | ✅ | AI 生成的问题描述 | |
| 139 | +| choices | array[string] | ❌ | AI 生成的建议选项 | |
| 140 | +| allow_freeform | boolean | ❌ | 是否允许自由输入(默认 true) | |
| 141 | + |
| 142 | +Property 结构体新增 `Items *Property` 字段以支持 `array` 类型: |
| 143 | +```go |
| 144 | +type Property struct { |
| 145 | + Type string `json:"type"` |
| 146 | + Description string `json:"description"` |
| 147 | + Enum []string `json:"enum,omitempty"` |
| 148 | + Items *Property `json:"items,omitempty"` |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +### 5. 系统提示词 |
| 153 | + |
| 154 | +**文件**: `mod/ai/prompt.go` |
| 155 | + |
| 156 | +工作原则新增第 5 条: |
| 157 | +``` |
| 158 | +5. **主动求助**:当遇到无法独立解决的问题时(如网络问题多种解决方案、 |
| 159 | + 多个可行方案难以抉择、命令执行失败需要用户指导),使用 ask_user 工具 |
| 160 | + 向用户提问,提供清晰的选项建议,让用户参与决策。不要在简单确认上使用, |
| 161 | + 仅在真正需要用户判断时使用 |
| 162 | +``` |
| 163 | + |
| 164 | +--- |
| 165 | + |
| 166 | +## 前端实现 |
| 167 | + |
| 168 | +### 1. 事件监听 |
| 169 | + |
| 170 | +**文件**: `AIChat.svelte` |
| 171 | + |
| 172 | +```javascript |
| 173 | +EventsOn('ai-agent-ask-user', (data) => { |
| 174 | + if (data.conversationId === currentConversationId) { |
| 175 | + askUserPending = { |
| 176 | + conversationId: data.conversationId, |
| 177 | + toolCallId: data.toolCallId, |
| 178 | + question: data.question, |
| 179 | + choices: data.choices || [], |
| 180 | + allowFreeform: data.allowFreeform !== false, |
| 181 | + }; |
| 182 | + askUserInput = ''; |
| 183 | + scrollToBottom(); |
| 184 | + } |
| 185 | +}); |
| 186 | +``` |
| 187 | + |
| 188 | +### 2. 提交回答 |
| 189 | + |
| 190 | +```javascript |
| 191 | +function submitAskUserAnswer(answer) { |
| 192 | + if (!askUserPending) return; |
| 193 | + const { conversationId, toolCallId } = askUserPending; |
| 194 | + // 更新工具卡片显示用户回答 |
| 195 | + agentToolCalls = agentToolCalls.map(tc => |
| 196 | + tc.id === toolCallId |
| 197 | + ? { ...tc, status: 'success', content: answer } |
| 198 | + : tc |
| 199 | + ); |
| 200 | + askUserPending = null; |
| 201 | + askUserInput = ''; |
| 202 | + SubmitAskUserResponse(conversationId, answer); |
| 203 | +} |
| 204 | +``` |
| 205 | + |
| 206 | +### 3. 交互卡片 UI |
| 207 | + |
| 208 | +渲染在 streaming 区域的 tool cards 下方: |
| 209 | + |
| 210 | +- 蓝色边框卡片(`border-2 border-blue-300 bg-blue-50/80`) |
| 211 | +- 问号图标 + "AI 需要你的决策" 标题 |
| 212 | +- AI 生成的问题文本 |
| 213 | +- 选项按钮列表(每个 choice 一个带编号的按钮) |
| 214 | +- 自由输入框 + 发送按钮(当 `allowFreeform=true`) |
| 215 | + |
| 216 | +### 4. 历史消息中的展示 |
| 217 | + |
| 218 | +ask_user 工具卡片使用蓝色主题区分于普通工具(绿/红/黄): |
| 219 | +- 图标:💬(替代普通工具的 🔧) |
| 220 | +- 边框:`border-2 border-blue-200` |
| 221 | +- 显示 AI 的问题和用户的回答(`↩ {answer}`) |
| 222 | + |
| 223 | +--- |
| 224 | + |
| 225 | +## 涉及文件 |
| 226 | + |
| 227 | +| 文件 | 改动 | |
| 228 | +|------|------| |
| 229 | +| `app_ai_chat.go` | askUserChannels 管理、SubmitAskUserResponse 方法、runAgentLoop ask_user 拦截 | |
| 230 | +| `mod/mcp/mcp.go` | Property 添加 Items 字段、ask_user 工具定义 | |
| 231 | +| `mod/ai/prompt.go` | 工作原则新增"主动求助"规则 | |
| 232 | +| `frontend/src/components/AI/AIChat.svelte` | EventsOn 监听、askUserPending 状态、交互卡片 UI、历史展示 | |
| 233 | +| `frontend/src/lib/i18n.js` | askUserTitle、askUserInputPlaceholder 中英文 | |
| 234 | +| `frontend/wailsjs/go/main/App.js` | SubmitAskUserResponse 绑定 | |
| 235 | +| `frontend/wailsjs/go/main/App.d.ts` | TypeScript 声明 | |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## 注意事项 |
| 240 | + |
| 241 | +1. **channel 生命周期**:每次 ask_user 创建 channel,用 defer 确保清理。若用户停止 Agent(`ctx.Done()`),channel 自动失效。 |
| 242 | +2. **并发安全**:`askUserChannels` 使用 `sync.Mutex` 保护,`SubmitAskUserResponse` 中 `select-default` 防止向已关闭 channel 写入阻塞。 |
| 243 | +3. **channel 容量为 1**:`make(chan string, 1)` 是 buffered channel,确保 `SubmitAskUserResponse` 不阻塞即使 runAgentLoop 还未开始 `select`。 |
| 244 | +4. **ask_user 不注册到 MCP ExecuteTool**:它不是真正的 MCP 工具——不需要在 `ExecuteTool` switch 中加 case,runAgentLoop 直接拦截处理。 |
| 245 | +5. **askUserPending 清理**:在 `ai-chat-complete`、sendMessage catch、stopAgent 时都会清 `null`,防止悬空。 |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## 功能开关(v3.1.4 补充) |
| 250 | + |
| 251 | +### 设计 |
| 252 | + |
| 253 | +ask_user 功能支持在凭据管理页 AI 配置区通过开关控制,默认开启。 |
| 254 | + |
| 255 | +- **AIConfig 新增字段**:`EnableAskUser *bool`(指针类型,`nil` = 默认开启,`false` = 关闭) |
| 256 | +- **后端过滤**:`runAgentLoop` 在构建 toolDefs 时,若 `EnableAskUser` 为 `false`,跳过 `ask_user` 工具定义,AI 不会看到该工具 |
| 257 | +- **前端开关**:紫色 toggle 按钮,放在 maxToolRounds 滑块下方 |
| 258 | + |
| 259 | +### 相关文件 |
| 260 | + |
| 261 | +| 文件 | 变更 | |
| 262 | +|------|------| |
| 263 | +| `mod/profile.go` | AIConfig 添加 `EnableAskUser *bool` 字段 | |
| 264 | +| `app_profile_project.go` | `UpdateProfileAIConfig` 新增 `enableAskUser` 参数 | |
| 265 | +| `app_ai_chat.go` | `runAgentLoop` 根据配置过滤 ask_user 工具 | |
| 266 | +| `frontend/src/components/Credentials/Credentials.svelte` | 新增 toggle 开关 UI | |
| 267 | +| `frontend/src/lib/i18n.js` | enableAskUser / enableAskUserHint 中英文 | |
| 268 | +| `frontend/wailsjs/go/main/App.js` / `App.d.ts` | UpdateProfileAIConfig 新增第 7 个参数 | |
0 commit comments