feat(core): 实现 ask_user_question 工具的用户输入等待机制

- 创建 UserInputWaiter 管理用户输入等待状态
- 修改 agent-tool-executor 在 requiresUserInput 时等待用户回答
- 添加 onWaitingForInput 回调通知前端显示问题
- Server 端处理 waiting_for_input 广播和 user_input_response 消息
- 前端处理问题显示和用户回答提交
- 修复问题选项在流式输出时被禁用的问题
This commit is contained in:
2025-12-17 00:44:25 +08:00
parent a4e8037108
commit 8c46635dc7
13 changed files with 351 additions and 53 deletions
@@ -278,7 +278,8 @@ export class AgentMessageHandler {
onToolEnd({
id: toolCallId,
status: output.success ? 'completed' : 'error',
result: output.success ? output.output : undefined,
// 传递完整的结果对象(包含 output 和 metadata),以支持 ask_user_question 等需要 metadata 的工具
result: output.success ? { output: output.output, metadata: output.metadata } : undefined,
error: output.success ? undefined : output.error,
duration,
});
+64 -11
View File
@@ -16,6 +16,7 @@ import {
} from '../agent/index.js';
import { getHookManager } from '../hooks/index.js';
import { getGitManager } from '../git/index.js';
import { getUserInputWaiter } from './user-input-waiter.js';
/**
* 工具调用开始事件信息
@@ -37,6 +38,16 @@ export interface ToolEndInfo {
duration?: number;
}
/**
* 等待用户输入事件信息
*/
export interface WaitingForInputInfo {
id: string;
toolName: string;
questions: unknown[];
args: Record<string, unknown>;
}
/**
* 工具执行上下文
*/
@@ -45,6 +56,8 @@ export interface ToolExecutionContext {
agentMode: AgentInfo | null;
onToolStart?: (info: ToolStartInfo) => void;
onToolEnd?: (info: ToolEndInfo) => void;
/** 当工具需要用户输入时调用(如 ask_user_question */
onWaitingForInput?: (info: WaitingForInputInfo) => void;
}
/**
@@ -175,8 +188,8 @@ export class AgentToolExecutor {
context: ToolExecutionContext,
hookManager: ReturnType<typeof getHookManager>
): Promise<ToolResult> {
const callId = `${tool.name}-${Date.now()}`;
const { sessionId, onToolStart, onToolEnd } = context;
const callId = `${tool.name}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const { sessionId, onToolStart, onToolEnd, onWaitingForInput } = context;
// 触发工具执行前 hook
let finalArgs = args;
@@ -221,7 +234,7 @@ export class AgentToolExecutor {
// 执行工具
const startTime = Date.now();
let result = await tool.execute(finalArgs);
const duration = Date.now() - startTime;
let duration = Date.now() - startTime;
// 触发工具执行后 hook
if (hookManager) {
@@ -243,14 +256,54 @@ export class AgentToolExecutor {
}
}
// 通知工具结束
onToolEnd?.({
id: callId,
status: result.success ? 'completed' : 'error',
result: result.success ? result.output : undefined,
error: result.success ? undefined : result.error,
duration,
});
// 检查是否需要用户输入(如 ask_user_question 工具)
if (result.success && result.metadata?.requiresUserInput) {
// 通知前端等待用户输入
const questions = (finalArgs.questions as unknown[]) || [];
onWaitingForInput?.({
id: callId,
toolName: tool.name,
questions,
args: finalArgs,
});
// 等待用户输入
const userInputWaiter = getUserInputWaiter();
try {
const userAnswer = await userInputWaiter.waitForInput(callId, tool.name);
// 用户回答后,更新结果
result = {
success: true,
output: `用户回答:\n${userAnswer}`,
metadata: {
...result.metadata,
userAnswer,
requiresUserInput: false, // 已获得输入
},
};
// 更新持续时间(包含等待用户输入的时间)
duration = Date.now() - startTime;
} catch (error) {
// 用户取消或超时
result = {
success: false,
output: '',
error: error instanceof Error ? error.message : '等待用户输入失败',
};
}
}
// 通知工具结束(注意:这里不再调用 onToolEnd,因为 agent-message-handler 会在 tool-result chunk 中处理)
// 但对于需要用户输入的工具,我们需要在这里调用,因为结果已经更新
if (result.metadata?.userAnswer !== undefined) {
onToolEnd?.({
id: callId,
status: result.success ? 'completed' : 'error',
result: result.success ? { output: result.output, metadata: result.metadata } : undefined,
error: result.success ? undefined : result.error,
duration,
});
}
// 如果是 tool_search 调用,解析结果并注入发现的工具
if (tool.name === 'tool_search' && result.success) {
+6 -3
View File
@@ -19,13 +19,13 @@ import { todoManager } from '../tools/todo/todo-manager.js';
import { initTaskContext } from '../tools/task/index.js';
// 子模块
import { AgentToolExecutor, type ToolStartInfo, type ToolEndInfo } from './agent-tool-executor.js';
import { AgentToolExecutor, type ToolStartInfo, type ToolEndInfo, type WaitingForInputInfo } from './agent-tool-executor.js';
import { AgentMessageHandler, type DoomLoopInfo } from './agent-message-handler.js';
import { AgentModeManager } from './agent-mode-manager.js';
import { AgentVisionHandler } from './agent-vision-handler.js';
// 重新导出类型
export type { ToolStartInfo, ToolEndInfo, DoomLoopInfo };
export type { ToolStartInfo, ToolEndInfo, DoomLoopInfo, WaitingForInputInfo };
/**
* Agent.chat() 选项
@@ -35,6 +35,8 @@ export interface AgentChatOptions {
onToolStart?: (info: ToolStartInfo) => void;
onToolEnd?: (info: ToolEndInfo) => void;
onDoomLoop?: (info: DoomLoopInfo) => void;
/** 当工具需要用户输入时调用(如 ask_user_question */
onWaitingForInput?: (info: WaitingForInputInfo) => void;
abortSignal?: AbortSignal;
}
@@ -155,7 +157,7 @@ export class Agent {
async chat(userMessage: string | UserInput, options?: AgentChatOptions | ((text: string) => void)): Promise<ChatResult> {
// 兼容旧的 onStream 参数
const opts: AgentChatOptions = typeof options === 'function' ? { onStream: options } : (options || {});
const { onStream, onToolStart, onToolEnd, onDoomLoop, abortSignal } = opts;
const { onStream, onToolStart, onToolEnd, onDoomLoop, onWaitingForInput, abortSignal } = opts;
if (!this.toolExecutor) {
throw new Error('工具注册表未初始化,请先调用 setRegistry()');
@@ -199,6 +201,7 @@ export class Agent {
sessionId: this.sessionManager?.getSession()?.id || 'default',
agentMode: this.modeManager.getCurrentMode(),
onToolEnd,
onWaitingForInput,
});
// 配置消息处理
+8
View File
@@ -11,8 +11,16 @@ export {
type ToolStartInfo,
type ToolEndInfo,
type ToolExecutionContext,
type WaitingForInputInfo,
} from './agent-tool-executor.js';
// 用户输入等待器
export {
getUserInputWaiter,
UserInputWaiter,
type PendingInput,
} from './user-input-waiter.js';
export {
AgentMessageHandler,
type DoomLoopInfo,
+117
View File
@@ -0,0 +1,117 @@
/**
* 用户输入等待管理器
*
* 用于处理需要用户输入的工具(如 ask_user_question)。
* 当工具需要用户输入时,会创建一个等待器,阻塞工具执行直到用户提交回答。
*/
export interface PendingInput {
toolCallId: string;
toolName: string;
resolve: (answer: string) => void;
reject: (error: Error) => void;
createdAt: number;
}
/**
* 用户输入等待管理器
*/
class UserInputWaiter {
private pendingInputs: Map<string, PendingInput> = new Map();
// 超时时间:10 分钟
private readonly timeout = 10 * 60 * 1000;
/**
* 等待用户输入
* @param toolCallId 工具调用 ID
* @param toolName 工具名称
* @returns 用户输入的答案
*/
async waitForInput(toolCallId: string, toolName: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const pending: PendingInput = {
toolCallId,
toolName,
resolve,
reject,
createdAt: Date.now(),
};
this.pendingInputs.set(toolCallId, pending);
// 设置超时
setTimeout(() => {
if (this.pendingInputs.has(toolCallId)) {
this.pendingInputs.delete(toolCallId);
reject(new Error(`等待用户输入超时 (${toolName})`));
}
}, this.timeout);
});
}
/**
* 提交用户输入
* @param toolCallId 工具调用 ID
* @param answer 用户的回答
* @returns 是否成功提交
*/
submitInput(toolCallId: string, answer: string): boolean {
const pending = this.pendingInputs.get(toolCallId);
if (!pending) {
return false;
}
this.pendingInputs.delete(toolCallId);
pending.resolve(answer);
return true;
}
/**
* 取消等待
* @param toolCallId 工具调用 ID
* @param reason 取消原因
*/
cancelInput(toolCallId: string, reason?: string): boolean {
const pending = this.pendingInputs.get(toolCallId);
if (!pending) {
return false;
}
this.pendingInputs.delete(toolCallId);
pending.reject(new Error(reason || '用户取消了输入'));
return true;
}
/**
* 检查是否有等待中的输入
*/
hasPendingInput(toolCallId: string): boolean {
return this.pendingInputs.has(toolCallId);
}
/**
* 获取所有等待中的输入
*/
getPendingInputs(): PendingInput[] {
return Array.from(this.pendingInputs.values());
}
/**
* 清除所有等待(用于会话结束时)
*/
clearAll(reason?: string): void {
for (const pending of this.pendingInputs.values()) {
pending.reject(new Error(reason || '会话已结束'));
}
this.pendingInputs.clear();
}
}
// 全局单例
const userInputWaiter = new UserInputWaiter();
export function getUserInputWaiter(): UserInputWaiter {
return userInputWaiter;
}
export { UserInputWaiter };
+5 -1
View File
@@ -1,5 +1,9 @@
export { Agent } from './core/agent.js';
export type { AgentChatOptions, ToolStartInfo, ToolEndInfo, DoomLoopInfo } from './core/agent.js';
export type { AgentChatOptions, ToolStartInfo, ToolEndInfo, DoomLoopInfo, WaitingForInputInfo } from './core/agent.js';
// User Input Waiter (用于 ask_user_question 等工具)
export { getUserInputWaiter, UserInputWaiter } from './core/user-input-waiter.js';
export type { PendingInput } from './core/user-input-waiter.js';
// Doom Loop Detection
export {