feat(core): 实现 ask_user_question 工具的用户输入等待机制
- 创建 UserInputWaiter 管理用户输入等待状态 - 修改 agent-tool-executor 在 requiresUserInput 时等待用户回答 - 添加 onWaitingForInput 回调通知前端显示问题 - Server 端处理 waiting_for_input 广播和 user_input_response 消息 - 前端处理问题显示和用户回答提交 - 修复问题选项在流式输出时被禁用的问题
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// 配置消息处理
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user