# 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 getInputSchema() { // 返回 JSON Schema,详见下方 } @Override public Map 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` 并注册到一个专用的 `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 private final Map> pending = new ConcurrentHashMap<>(); public CompletableFuture register(Long sessionId, String requestId) { CompletableFuture future = new CompletableFuture<>(); pending.put(key(sessionId, requestId), future); return future; } public void complete(Long sessionId, String requestId, String result) { CompletableFuture 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) │ │
│ │ │ │ │ Fixed left panel │ │
│ │ │ │ │ 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 } ``` ### 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 按钮禁用 |