docs/plan/2026-06-25-ask-user-questions-design.md
Rama main · réplica de lectura del repositorio original.
# Agent 向用户提问 — 技术方案设计
> 版本: v1.0 | 日期: 2026-06-24 | 状态: 待评审
## 1. 需求概述
Agent 在执行任务过程中,需要在不中断任务流的情况下向用户发起提问,用于:
- 获取用户偏好或需求澄清
- 模糊指令的二次确认
- 实现方案的多选一决策
- 提供选项让用户选择方向
底层基于 ToolCall 机制实现,工具名为 `ask_user_questions`。
## 2. 核心设计思路
### 2.1 与现有机制的类比
本功能在架构上与 **LOCAL 模式工具审批流** 高度相似:
| 维度 | 工具审批 (approval) | 向用户提问 (ask_user_questions) |
| ----- | ----------------------- | -------------------------- |
| 触发方 | ToolDispatcher 根据权限判断 | LLM 主动调用工具 |
| 阻塞方式 | CompletableFuture.get() | CompletableFuture.get() |
| 客户端渲染 | ApprovalStack 卡片 | QuestionPanel 问答面板 |
| 用户操作 | 批准 / 拒绝 | 选择选项 / 自由输入 |
| 返回值 | 执行结果或拒绝错误 | 用户回答的结构化 JSON |
| 执行模式 | 仅 LOCAL 模式 | **CLOUD 和 LOCAL 均可** |
关键区别:审批是安全机制,由 ToolDispatcher 根据权限等级决定;提问是能力工具,由 LLM 自主决定何时调用。因此提问工具需要**独立于权限系统**,在任何执行模式下都能将问题路由到客户端。
### 2.2 端到端流程
```
LLM 输出 tool_call: ask_user_questions
│
▼
AgentLoop.executeToolCalls()
│
▼
ToolDispatcher.dispatch()
│ 识别为特殊工具,路由到客户端
▼
发送 ask_user_questions WsEvent → 客户端
│
▼
客户端渲染 QuestionPanel 组件
│ 用户选择选项 或 输入自定义文本
▼
客户端发送 ask_user_questions_result WsEvent → 服务端
│
▼
CompletableFuture 完成,AgentLoop 继续
│
▼
LLM 收到 tool_result,继续执行任务
```
## 3. 后端设计
### 3.1 新增工具:AskUserQuestionsTool
**文件**: `backend/src/main/java/cn/etarch/mao/harness/tool/impl/AskUserQuestionsTool.java`
```java
@Component
public class AskUserQuestionsTool implements Tool {
@Override
public String getName() {
return "ask_user_questions";
}
@Override
public String getDescription() {
return """
Use this tool when you need to ask the user questions during execution. \
This allows you to gather user preferences or requirements, clarify ambiguous \
instructions, get decisions on implementation choices as you work, or offer \
choices to the user about what direction to take.
\
Usage notes:
- Users will always be able to select "Other" to provide custom text input
- Use multiSelect: true to allow multiple answers to be selected
- If you recommend a specific option, make it the first in the list and add \
"(Recommended)" at the end of the label
- preview supports rendered markdown in a monospace box, multi-line with newlines
- Previews only render for single-select questions (not multiSelect)
- When any option has preview, UI switches to side-by-side layout
""";
}
@Override
public Map<String, Object> getInputSchema() {
// 返回 JSON Schema,详见下方
}
@Override
public Map<String, Object> getOutputSchema() {
// 返回 JSON Schema
}
@Override
public String execute(String arguments, Long sessionId, String workspace) {
// 此方法不应被直接调用 — 由特殊路由处理
// 如果被调用,返回错误提示
return "{\"error\": \"ask_user_questions must be dispatched to client\"}";
}
}
```
**Input Schema**:
```json
{
"type": "object",
"required": ["questions"],
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"required": ["question", "header", "options", "multiSelect"],
"properties": {
"question": {
"type": "string",
"description": "The complete question, ending with ?"
},
"header": {
"type": "string",
"maxLength": 12,
"description": "Very short label, shown as chip/tag"
},
"options": {
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": {
"type": "object",
"required": ["label", "description"],
"properties": {
"label": {
"type": "string",
"description": "Display text, 1-5 words"
},
"description": {
"type": "string",
"description": "Explanation of what this option means"
},
"preview": {
"type": "string",
"description": "Optional preview content (mockups, code, diagrams)"
}
}
}
},
"multiSelect": {
"type": "boolean",
"description": "true = multiple answers allowed"
}
}
}
},
"metadata": {
"type": "object",
"description": "Optional tracking metadata"
}
}
}
```
**Output Schema**:
```json
{
"type": "object",
"properties": {
"answers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"question": { "type": "string" },
"selectedLabels": {
"type": "array",
"items": { "type": "string" }
},
"customInput": { "type": "string" }
}
}
}
}
}
```
### 3.2 ToolDispatcher 路由改造
**文件**: `backend/src/main/java/cn/etarch/mao/harness/tool/ToolDispatcher.java`
新增特殊路由判断。`ask_user_questions` 不属于 `SERVER_ONLY_TOOLS`(那组是 task_* 工具),需要独立处理:
```java
// 新增常量
private static final String ASK_USER_QUESTIONS = "ask_user_questions";
// dispatch() 方法中新增分支(在 SERVER_ONLY_TOOLS 判断之后)
if (ASK_USER_QUESTIONS.equals(toolName)) {
return dispatchAskUserQuestions(arguments, sessionId, workspace);
}
```
`dispatchAskUserQuestions()` 方法实现:
1. 生成 `requestId`(UUID)
2. 构建 `WsEvent`(type = `ask_user_questions`),data 中包含 `requestId` + 工具参数
3. 通过 `StreamingWsRegistry.send()` 发送给用户
4. 创建 `CompletableFuture<String>` 并注册到一个专用的 `AskUserQuestionsRegistry`
5. 阻塞等待 `future.get()`,设置超时(默认 5 分钟)
6. 超时返回 `{"error": "User did not respond within timeout"}`
**为什么不用 LocalToolExecutor**: LocalToolExecutor 绑定了 LOCAL 模式的连接管理(检查客户端是否在线、发送 tool_execute 事件),而提问工具需要在 CLOUD 模式下也能工作。需要独立的发送/等待机制。
### 3.3 新增 AskUserQuestionsRegistry
**文件**: `backend/src/main/java/cn/etarch/mao/harness/tool/AskUserQuestionsRegistry.java`
```java
@Component
public class AskUserQuestionsRegistry {
// key: (sessionId, requestId) -> CompletableFuture<String>
private final Map<String, CompletableFuture<String>> pending = new ConcurrentHashMap<>();
public CompletableFuture<String> register(Long sessionId, String requestId) {
CompletableFuture<String> future = new CompletableFuture<>();
pending.put(key(sessionId, requestId), future);
return future;
}
public void complete(Long sessionId, String requestId, String result) {
CompletableFuture<String> future = pending.remove(key(sessionId, requestId));
if (future != null) {
future.complete(result);
}
}
public void failAllForSession(Long sessionId) {
// 会话取消时,清理所有待响应的提问
pending.entrySet().removeIf(e -> {
if (e.getKey().startsWith(sessionId + ":")) {
e.getValue().complete("{\"error\": \"Session cancelled\"}");
return true;
}
return false;
});
}
private String key(Long sessionId, String requestId) {
return sessionId + ":" + requestId;
}
}
```
### 3.4 WebSocket 协议扩展
**新增事件类型**:
| 方向 | type | data 字段 | 说明 |
| --------------- | --------------------------- | ------------------------------------ | ---- |
| Server → Client | `ask_user_questions` | `{ requestId, questions, metadata }` | 发送提问 |
| Client → Server | `ask_user_questions_result` | `{ requestId, answers }` | 返回回答 |
**WsEvent 示例 — Server → Client**:
```json
{
"type": "ask_user_questions",
"sessionId": 123,
"data": {
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"questions": [
{
"question": "Which framework should we use for the new module?",
"header": "Framework",
"options": [
{ "label": "Vue 3 (Recommended)", "description": "Composition API + TypeScript, matches existing stack" },
{ "label": "React", "description": "Larger ecosystem but different from current codebase" }
],
"multiSelect": false
}
]
}
}
```
**WsEvent 示例 — Client → Server**:
```json
{
"type": "ask_user_questions_result",
"sessionId": 123,
"data": {
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"answers": [
{
"question": "Which framework should we use for the new module?",
"selectedLabels": ["Vue 3 (Recommended)"],
"customInput": null
}
]
}
}
```
### 3.5 StreamingWsHandler 处理
**文件**: `backend/src/main/java/cn/etarch/mao/session/ws/StreamingWsHandler.java`
在 `handleTextMessage()` 的 switch 分支中新增:
```java
case "ask_user_questions_result" -> handleAskUserQuestionsResult(sessionId, data);
```
`handleAskUserQuestionsResult()` 实现:
1. 从 data 中提取 `requestId` 和 `answers`
2. 将 answers 序列化为 JSON 字符串
3. 调用 `askUserQuestionsRegistry.complete(sessionId, requestId, jsonResult)`
### 3.6 取消处理
在 `StreamingWsHandler.handleCancel()` 中,除了现有的 `localToolSessionRegistry.failAllForUser()` 外,新增:
```java
askUserQuestionsRegistry.failAllForSession(sessionId);
```
确保取消时 AgentLoop 中阻塞的 CompletableFuture 能被释放。
### 3.7 AgentLoop 无感知
AgentLoop 本身**不需要任何改动**。它只看到:
1. `dispatchTool()` 返回了一个 JSON 字符串(用户的回答)
2. 将结果加入 context,继续循环
这与现有工具执行模式完全一致。
### 3.8 ToolResultSummarizer 扩展
**文件**: `backend/src/main/java/cn/etarch/mao/session/util/ToolResultSummarizer.java`
新增对 `ask_user_questions` 的摘要生成:
```java
case "ask_user_questions" -> {
// 解析 answers,生成类似 "回答了 2 个问题" 的摘要
yield "回答了 " + answerCount + " 个问题";
}
```
## 4. 桌面端设计
### 4.1 WebSocket 事件路由
**文件**: `desktop/src/composables/useStreamWS.ts`
在 `routeEvent()` 中新增 case:
```typescript
case 'ask_user_questions':
sessionStore.appendAskQuestion(event.data)
break
```
**文件**: `desktop/src/stores/session.ts`
新增 store 方法 `appendAskQuestion(data)`:
- 将提问数据存入当前活跃会话的 `pendingQuestions` 队列
- 类似于 `pendingApprovals` 的管理模式
### 4.2 新增 QuestionPanel 组件
**文件**: `desktop/src/components/chat/QuestionPanel.vue`
渲染位置:与 `ApprovalStack` 同级,位于聊天区域底部或消息流中。
**UI 结构**:
```
┌─────────────────────────────────────────────────┐
│ Agent wants to know: │
│ │
│ ┌─ Framework ─────────────────────────────┐ │
│ │ Which framework should we use for the │ │
│ │ new module? │ │
│ │ │ │
│ │ ○ Vue 3 (Recommended) │ │
│ │ Composition API + TypeScript, │ │
│ │ matches existing stack │ │
│ │ │ │
│ │ ○ React │ │
│ │ Larger ecosystem but different from │ │
│ │ current codebase │ │
│ │ │ │
│ │ ┌─ Other ──────────────────────────┐ │ │
│ │ │ Type your own answer... │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [ Submit ] │
└─────────────────────────────────────────────────┘
```
**带 Preview 的 side-by-side 布局**:
```
┌──────────────────────────────────────────────────────┐
│ Agent wants to know: │
│ │
│ ┌─ Layout ───────────────────┬─ Preview ──────────┐ │
│ │ Which layout approach? │ ┌────────────────┐ │ │
│ │ │ │ // mockup code │ │ │
│ │ ○ Sidebar (Recommended) │ │ <div class="s"> │ │ │
│ │ Fixed left panel │ │ <aside/> │ │ │
│ │ │ │ <main/> │ │ │
│ │ ○ Top bar │ │ </div> │ │ │
│ │ Horizontal navigation │ │ │ │ │
│ │ │ └────────────────┘ │ │
│ │ ┌─ Other ──────────────┐ │ │ │
│ │ │ Type your answer... │ │ │ │
│ │ └──────────────────────┘ │ │ │
│ └────────────────────────────┴────────────────────┘ │
│ │
│ [ Submit ] │
└──────────────────────────────────────────────────────┘
```
**Multi-select 布局**:
```
┌─────────────────────────────────────────────────┐
│ Agent wants to know: │
│ │
│ ┌─ Features ──────────────────────────────┐ │
│ │ Which features should we include? │ │
│ │ │ │
│ │ ☑ Dark mode │ │
│ │ Toggle between light and dark themes │ │
│ │ │ │
│ │ ☐ i18n support │ │
│ │ Multi-language internationalization │ │
│ │ │ │
│ │ ☑ Export to PDF │ │
│ │ Generate PDF reports │ │
│ │ │ │
│ │ ┌─ Other ──────────────────────────┐ │ │
│ │ │ Type your own answer... │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [ Submit ] │
└─────────────────────────────────────────────────┘
```
### 4.3 组件接口设计
```typescript
interface QuestionOption {
label: string
description: string
preview?: string
}
interface Question {
question: string
header: string
options: QuestionOption[]
multiSelect: boolean
}
interface QuestionAnswer {
question: string
selectedLabels: string[]
customInput: string | null
}
interface PendingQuestion {
requestId: string
questions: Question[]
metadata?: Record<string, unknown>
}
```
### 4.4 用户交互逻辑
**单选 (multiSelect: false)**:
- 点击选项切换选中状态(radio 行为)
- 同一问题只能选中一个选项
- "Other" 输入框始终可见,输入文本时自动取消选项选中
- 选中选项时清空 "Other" 输入框
- Submit 按钮在至少选中一个选项或 "Other" 有内容时可用
**多选 (multiSelect: true)**:
- 点击选项切换选中状态(checkbox 行为)
- 可选中多个选项
- "Other" 输入框始终可见
- Submit 按钮在至少选中一个选项或 "Other" 有内容时可用
**Preview 渲染**:
- 仅在单选模式且选项含 preview 时渲染
- 切换到 side-by-side 布局
- 预览内容以 markdown 渲染在 monospace 容器中
- 选中不同选项时,右侧预览跟随切换
### 4.5 回答提交
用户点击 Submit 后:
1. 收集所有问题的回答,构建 `answers` 数组
2. 通过 useStreamWS 发送 `ask_user_questions_result` 事件:
```typescript
ws.send(JSON.stringify({
type: 'ask_user_questions_result',
sessionId: activeSessionId,
data: {
requestId: pendingQuestion.requestId,
answers: collectedAnswers
}
}))
```
3. 从 store 中移除该 pendingQuestion,QuestionPanel 消失
回答不需要以用户消息形式插入消息流。tool_call(包含问题)和 tool_result(包含回答)已完整记录在消息历史中,ToolCallCard 的 Input/Output 区域即可展示问答详情。
### 4.6 与 ApprovalStack 的共存
QuestionPanel 和 ApprovalStack 是独立的交互通道,可以同时存在:
- ApprovalStack 处理工具执行审批(安全机制)
- QuestionPanel 处理 Agent 提问(信息收集)
两者在 UI 上不冲突,各自管理自己的队列。当同时存在时,QuestionPanel 显示在 ApprovalStack 上方(因为提问通常更紧急,需要用户关注)。
## 5. 数据持久化
### 5.1 消息存储
提问和回答作为普通的 tool_call + tool_result 对存储,无需新增表或字段:
- **Assistant 消息**:包含 `tool_call` 记录,toolName = `ask_user_questions`,arguments = 完整的 questions JSON
- **Tool 消息**:role = `TOOL`,toolCallId 关联到上述 tool_call,content = answers JSON
现有消息查询和回放机制自动支持。
### 5.2 历史回放
当用户重新打开会话或刷新页面时:
- 消息从 DB 加载,`ask_user_questions` 的 tool_call 和 tool_result 正常展示在 ToolCallCard 中
- 不会重新弹出 QuestionPanel(那是实时交互组件)
## 6. 边界情况处理
### 6.1 超时
- 默认超时:5 分钟
- 超时后返回 `{"error": "User did not respond within timeout"}`
- LLM 收到超时错误后可自行决定是否重试或换一种方式继续
- 超时时间可通过 application.yml 配置
### 6.2 会话取消
- 用户取消时,`failAllForSession()` 释放所有阻塞的 CompletableFuture
- 返回 `{"error": "Session cancelled"}`
- AgentLoop 检测到取消标志后退出循环
### 6.3 WebSocket 断连
- 客户端断连时,服务端的 CompletableFuture 仍阻塞
- 超时机制作为兜底
- 可选:在 WebSocket close handler 中也触发 failAllForSession
### 6.4 多设备
- 同一用户多设备登录时,`StreamingWsRegistry.send()` 会广播到所有连接
- 任一设备回答即可,先到先得
- 其他设备收到回答后应清除本地的 QuestionPanel
### 6.5 队列消息
- 当 Agent 正在等待用户回答时,新消息应进入队列(与 RUNNING 状态一致)
- 用户回答后 Agent 继续执行,完成后自动消费队列
## 7. 文件变更清单
### 后端
| 文件 | 变更类型 | 说明 |
| --------------------------------------------- | ------ | ------------------------------- |
| `harness/tool/impl/AskUserQuestionsTool.java` | **新增** | 工具定义 |
| `harness/tool/AskUserQuestionsRegistry.java` | **新增** | 提问等待注册表 |
| `harness/tool/ToolDispatcher.java` | 修改 | 新增 ask_user_questions 路由分支 |
| `session/ws/StreamingWsHandler.java` | 修改 | 处理 ask_user_questions_result 事件 |
| `session/ws/WsEvent.java` | 无变更 | WsEvent 是通用 envelope,无需改 |
| `session/util/ToolResultSummarizer.java` | 修改 | 新增 ask_user_questions 摘要 |
| `harness/core/AgentLoop.java` | 无变更 | 无需感知此工具 |
| `application.yml` | 修改 | 新增超时配置项(可选) |
### 桌面端
| 文件 | 变更类型 | 说明 |
| ----------------------------------- | ------ | ------------------------- |
| `components/chat/QuestionPanel.vue` | **新增** | 提问渲染组件 |
| `composables/useStreamWS.ts` | 修改 | 路由 ask_user_questions 事件 |
| `stores/session.ts` | 修改 | 新增 pendingQuestions 状态和方法 |
| `types/chat.ts` | 修改 | 新增 Question 相关类型定义 |
| `composables/useChat.ts` | 修改 | 提问回答提交逻辑 |
| `components/chat/MessageBubble.vue` | 可选修改 | 提问工具调用的展示优化 |
### 管理后台
无变更。管理后台不涉及实时对话交互。
## 8. 实施计划
一次性实施落地,包含以下工作项:
**后端**:
1. 新增 `AskUserQuestionsTool` — 工具定义(name、description、inputSchema、outputSchema)
2. 新增 `AskUserQuestionsRegistry` — 提问等待注册表(CompletableFuture 管理)
3. 修改 `ToolDispatcher` — 新增 `ask_user_questions` 路由分支,绕过权限系统直接路由到客户端
4. 修改 `StreamingWsHandler` — 处理 `ask_user_questions_result` 事件 + 取消时 failAll
5. 修改 `ToolResultSummarizer` — 新增 `ask_user_questions` 摘要
**桌面端**:
6. 新增 `QuestionPanel.vue` — 提问渲染组件,支持单选/多选/自由输入/preview side-by-side 布局
7. 修改 `useStreamWS.ts` — 路由 `ask_user_questions` 事件到 store
8. 修改 `session.ts` — 新增 `pendingQuestions` 状态和 `appendAskQuestion` / `removeAskQuestion` 方法
9. 修改 `chat.ts` — 新增 Question 相关 TypeScript 类型定义
10. 修改 `useChat.ts` — 提问回答提交逻辑
11. 修改 `TaskView.vue` — 在聊天区域底部挂载 `QuestionPanel`,与 `ApprovalStack` 同级
**不包含**:压力测试、单元测试、管理后台变更。
## 9. 设计决策记录
| # | 问题 | 决策 |
|---|------|------|
| 1 | 回答是否以用户消息形式插入消息流? | **否**。tool_call + tool_result 已包含完整问答,无需额外插入用户消息 |
| 2 | 是否限制提问频率? | **不限制**。LLM 可在一轮对话中多次调用此工具 |
| 3 | QuestionPanel 的位置? | **固定在聊天区域底部**(类似 ApprovalStack),长对话时不会被滚动出视野 |
| 4 | "Other" 输入是否需要非空校验? | **是**。选中 "Other" 但未输入内容时,Submit 按钮禁用 |