Skip to content

Commit 0559cf0

Browse files
committed
feat(agent): 新增 ask_user 人机协作工具
新增 ask_user 工具使 AI Agent 在遇到决策点时能暂停执行并向用户提问。用户可通过前端 UI 选择 AI 生成的建议选项或自由输入,回答后 Agent 继续执行。 - 后端实现 ask_user 工具拦截、通道管理和用户回答接收 - 前端新增交互卡片 UI 和事件处理逻辑 - 凭据管理页添加功能开关控制 ask_user 工具可用性 - 更新系统提示词和工具定义文档 - 版本号更新至 v3.1.4
1 parent a8c6620 commit 0559cf0

File tree

16 files changed

+552
-19
lines changed

16 files changed

+552
-19
lines changed

app_ai_chat.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ var agentCancelMap = struct {
2424
m map[string]context.CancelFunc
2525
}{m: make(map[string]context.CancelFunc)}
2626

27+
// askUserChannels stores channels for ask_user tool responses, keyed by conversationId
28+
var askUserChannels = struct {
29+
sync.Mutex
30+
m map[string]chan string
31+
}{m: make(map[string]chan string)}
32+
2733
// AIChatMessage represents a single message in the AI chat conversation
2834
type AIChatMessage struct {
2935
Role string `json:"role"`
@@ -162,6 +168,19 @@ func (a *App) StopAgentStream(conversationId string) {
162168
agentCancelMap.Unlock()
163169
}
164170

171+
// SubmitAskUserResponse sends user's answer back to a waiting ask_user tool call
172+
func (a *App) SubmitAskUserResponse(conversationId string, answer string) {
173+
askUserChannels.Lock()
174+
ch, ok := askUserChannels.m[conversationId]
175+
askUserChannels.Unlock()
176+
if ok {
177+
select {
178+
case ch <- answer:
179+
default:
180+
}
181+
}
182+
}
183+
165184
// ExportChatLog saves chat log content to a user-selected file
166185
func (a *App) ExportChatLog(content string) error {
167186
filename := fmt.Sprintf("redc-chat-%s.md", time.Now().Format("20060102-150405"))
@@ -219,8 +238,12 @@ func (a *App) runAgentLoop(conversationId string, messages []AIChatMessage, prom
219238
// Build tool definitions from MCP server
220239
mcpServer := mcp.NewMCPServer(project, a)
221240
mcpTools := mcpServer.GetTools()
241+
enableAskUser := aiConfig.EnableAskUser == nil || *aiConfig.EnableAskUser // default true
222242
toolDefs := make([]ai.ToolDefinition, 0, len(mcpTools))
223243
for _, t := range mcpTools {
244+
if t.Name == "ask_user" && !enableAskUser {
245+
continue
246+
}
224247
params := map[string]interface{}{
225248
"type": t.InputSchema.Type,
226249
"properties": t.InputSchema.Properties,
@@ -356,6 +379,51 @@ func (a *App) runAgentLoop(conversationId string, messages []AIChatMessage, prom
356379
// Report JSON parse failure as tool result so AI knows the root cause
357380
resultContent = fmt.Sprintf("工具参数 JSON 解析失败: %v\n原始参数: %s", jsonParseErr, tc.Function.Arguments)
358381
success = false
382+
} else if tc.Function.Name == "ask_user" {
383+
// Special handling: pause loop, emit event, wait for user response
384+
question, _ := args["question"].(string)
385+
var choices []string
386+
if rawChoices, ok := args["choices"].([]interface{}); ok {
387+
for _, c := range rawChoices {
388+
if s, ok := c.(string); ok {
389+
choices = append(choices, s)
390+
}
391+
}
392+
}
393+
allowFreeform := true
394+
if af, ok := args["allow_freeform"].(bool); ok {
395+
allowFreeform = af
396+
}
397+
398+
// Create response channel
399+
ch := make(chan string, 1)
400+
askUserChannels.Lock()
401+
askUserChannels.m[conversationId] = ch
402+
askUserChannels.Unlock()
403+
defer func() {
404+
askUserChannels.Lock()
405+
delete(askUserChannels.m, conversationId)
406+
askUserChannels.Unlock()
407+
}()
408+
409+
// Emit ask_user event to frontend
410+
a.emitEvent("ai-agent-ask-user", map[string]interface{}{
411+
"conversationId": conversationId,
412+
"toolCallId": tc.ID,
413+
"question": question,
414+
"choices": choices,
415+
"allowFreeform": allowFreeform,
416+
})
417+
418+
// Wait for user response or cancellation
419+
select {
420+
case answer := <-ch:
421+
resultContent = answer
422+
success = true
423+
case <-ctx.Done():
424+
resultContent = "用户未回答,操作已取消"
425+
success = false
426+
}
359427
} else {
360428
result, execErr := mcpServer.ExecuteTool(tc.Function.Name, args)
361429
success = execErr == nil

app_profile_project.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,13 +336,14 @@ func (a *App) DeleteProfile(profileID string) error {
336336
return redc.DeleteProfile(profileID)
337337
}
338338

339-
func (a *App) UpdateProfileAIConfig(profileID string, provider string, apiKey string, baseUrl string, model string, maxToolRounds int) error {
339+
func (a *App) UpdateProfileAIConfig(profileID string, provider string, apiKey string, baseUrl string, model string, maxToolRounds int, enableAskUser bool) error {
340340
aiConfig := &redc.AIConfig{
341341
Provider: provider,
342342
APIKey: apiKey,
343343
BaseURL: baseUrl,
344344
Model: model,
345345
MaxToolRounds: maxToolRounds,
346+
EnableAskUser: &enableAskUser,
346347
}
347348
return redc.UpdateProfileAIConfig(profileID, aiConfig)
348349
}

doc/design/3.45-agent-ask-user.md

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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 个参数 |

frontend/public/changelog.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"version": "3.1.4",
55
"date": "2026-03-15",
66
"changes": [
7-
"新增:Shadowsocks-libev Userdata 模板 — 从 proxy 预定义模板中提取独立 userdata 模板,参数化 SS_PORT/SS_PASSWORD/SS_METHOD 环境变量,修复重复 apt install、添加 apt lock 等待",
7+
"新增:Agent ask_user 人机协作工具 — AI Agent 遇到需要用户决策的问题时(如网络故障多种解决方案、费用确认、多方案选择),自动弹出交互卡片展示 AI 生成的选项建议,用户可点选或自由输入,回答后 Agent 继续执行",
8+
"新增:ask_user 功能开关 — 凭据管理页 AI 配置区新增「Agent 人机协作决策」开关,默认开启,关闭后 Agent 不再向用户询问,适合全自动执行场景",
89
"新增:AI 对话消息时间戳 — 用户消息右下角、助手消息左下角显示发送时间,当天显示 HH:MM,跨天显示日期+时间",
910
"优化:MCP 工具安全增强 — stop_case 要求目标状态为 running,kill_case 拒绝已 stopped 的场景;工具描述增加状态前置条件说明",
1011
"优化:AI Agent 作用域限制 — 系统提示词新增「仅操作当前对话创建的场景」和「状态感知」规则,防止误操作未关联的场景",

0 commit comments

Comments
 (0)