diff --git a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx index d7ebc2ef4..f21f99264 100644 --- a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 + * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 + * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 + * 过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 665ded047..5b624eda5 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1244,6 +1244,11 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", + "settings.interactiveTimeout.unit": "分钟", + "settings.interactiveTimeout.hint": + "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -3466,6 +3471,12 @@ export const translations: Record> = { "settings.systemTools": "System Tools", "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", + "settings.interactiveTimeout.title": "Interactive answer timeout", + "settings.interactiveTimeout.desc": + "Wait window shared by the AskUserQuestion card and the tool approval bar.", + "settings.interactiveTimeout.unit": "min", + "settings.interactiveTimeout.hint": + "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", diff --git a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts index 38fdf437e..a5748cfa0 100644 --- a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts @@ -7,7 +7,9 @@ export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; export const ASK_USER_QUESTION_MAX_QUESTIONS = 4; export const ASK_USER_QUESTION_MIN_OPTIONS = 2; export const ASK_USER_QUESTION_MAX_OPTIONS = 6; -/** 每轮提问的应答窗口:超时后按推荐项(缺省第一项)自动落定继续执行。 */ +/** 每轮提问的应答窗口默认值(毫秒):超时后按推荐项(缺省第一项)自动落定继续执行。 + * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), + * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 7f3fbeafd..d2f3bc4a6 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -180,6 +180,11 @@ export type SystemProxyConfig = { /** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */ export type ToolPolicy = "allow" | "ask" | "deny"; +/** 交互式应答(AskUserQuestion 提问卡 + 工具审批栏)的等待窗口,单位分钟。 + * 正数 = 超时窗口,超时后按各交互既定姿态落定(提问自动选推荐项并继续、 + * 审批按拒绝)。两处交互共用同一窗口。填很大的数(如 99999)≈ 永不超时。 */ +export const INTERACTIVE_TIMEOUT_DEFAULT_MINUTES = 3; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -188,6 +193,8 @@ export type SystemSettings = { * 回写会丢掉桌面端设置的策略。策略的裁决在桌面端 resolveToolPolicy。 */ toolPolicies?: Record; + /** 交互式应答超时(分钟):正数=窗口,超长≈永不。与桌面端对齐原样透传。 */ + interactiveTimeoutMinutes: number; workspaceProjects: WorkspaceProject[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; @@ -1678,12 +1685,20 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig { }; } +/** 归一化交互式应答超时(分钟):正数=窗口(超长≈永不),缺省/非法/非正回 3。 */ +export function normalizeInteractiveTimeoutMinutes(input: unknown): number { + return typeof input === "number" && Number.isFinite(input) && input > 0 + ? input + : INTERACTIVE_TIMEOUT_DEFAULT_MINUTES; +} + export function normalizeSystemSettings(input: unknown): SystemSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; return { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + interactiveTimeoutMinutes: normalizeInteractiveTimeoutMinutes(obj.interactiveTimeoutMinutes), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() @@ -2235,6 +2250,7 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES, }, customProviders, mcp: { diff --git a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx index c81f90cdd..8599c29dd 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -5,9 +5,10 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; +import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; @@ -51,6 +52,22 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 + // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 + const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; + const [timeoutDraft, setTimeoutDraft] = useState(null); + const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); + const commitTimeoutDraft = () => { + if (timeoutDraft === null) return; + const parsed = Number.parseInt(timeoutDraft, 10); + if (!Number.isFinite(parsed)) { + setTimeoutDraft(null); + return; + } + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); + setTimeoutDraft(null); + }; + return (
@@ -70,6 +87,36 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
+
+
+
+
{t("settings.interactiveTimeout.title")}
+

+ {t("settings.interactiveTimeout.desc")} +

+
+
+ setTimeoutDraft(event.currentTarget.value)} + onBlur={commitTimeoutDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className="w-20" + /> + + {t("settings.interactiveTimeout.unit")} + +
+
+

+ {t("settings.interactiveTimeout.hint")} +

+
+
{groups.map(({ category, entries }) => (
diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 36f31e4b8..25f295d43 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -36,6 +36,8 @@ import { } from "./lib/settings/sync"; import { applyStoredGlobalShortcuts } from "./lib/shortcuts/globalShortcuts"; import { applyFontFamilies } from "./lib/system/fontFamily"; +import { setAskUserQuestionTimeoutMs } from "./lib/tools/askUserQuestionTools"; +import { setToolApprovalTimeoutMs } from "./lib/tools/toolApproval"; import { ChatPage } from "./pages/ChatPage"; import { SettingsPage } from "./pages/SettingsPage"; import type { SectionId } from "./pages/settings/types"; @@ -380,6 +382,16 @@ export default function App() { const getMcpSettings = useCallback(() => settingsRef.current.mcp, []); const getToolPolicies = useCallback(() => settingsRef.current.system.toolPolicies, []); + // 把交互式应答超时设置(分钟)注入工具运行时窗口(毫秒):AskUserQuestion 与 + // 工具审批共用同一窗口;永不超时用很大的分钟数表达。设置变更后新挂起的提问/ + // 审批生效,已挂起的沿用旧窗口。模块级配置由工具侧 ensureAskUserQuestionDeadlineAt / + // requestToolApproval 读取,避免在 6+ 处工具预览调用点逐个传参。 + useEffect(() => { + const ms = settings.system.interactiveTimeoutMinutes * 60_000; + setAskUserQuestionTimeoutMs(ms); + setToolApprovalTimeoutMs(ms); + }, [settings.system.interactiveTimeoutMinutes]); + const reloadPersistedSettings = useCallback(async () => { await saveChainRef.current.catch(() => undefined); const { settings: loaded, defaultWorkdir } = await loadPersistedSettingsWithDefaults(); diff --git a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx index d7ebc2ef4..f21f99264 100644 --- a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时 + * 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把 + * 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正 + * 过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt ? deadlineAt : mountedAt + ASK_USER_QUESTION_TIMEOUT_MS; const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now()); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 1d5a9014a..3ec3609bc 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1314,6 +1314,11 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。", + "settings.interactiveTimeout.unit": "分钟", + "settings.interactiveTimeout.hint": + "正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -3629,6 +3634,12 @@ export const translations: Record> = { "settings.systemTools": "System Tools", "settings.systemToolsDesc": "View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).", + "settings.interactiveTimeout.title": "Interactive answer timeout", + "settings.interactiveTimeout.desc": + "Wait window shared by the AskUserQuestion card and the tool approval bar.", + "settings.interactiveTimeout.unit": "min", + "settings.interactiveTimeout.hint": + "Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", diff --git a/crates/agent-gui/src/lib/chat/askUserQuestion.ts b/crates/agent-gui/src/lib/chat/askUserQuestion.ts index 38fdf437e..a5748cfa0 100644 --- a/crates/agent-gui/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gui/src/lib/chat/askUserQuestion.ts @@ -7,7 +7,9 @@ export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; export const ASK_USER_QUESTION_MAX_QUESTIONS = 4; export const ASK_USER_QUESTION_MIN_OPTIONS = 2; export const ASK_USER_QUESTION_MAX_OPTIONS = 6; -/** 每轮提问的应答窗口:超时后按推荐项(缺省第一项)自动落定继续执行。 */ +/** 每轮提问的应答窗口默认值(毫秒):超时后按推荐项(缺省第一项)自动落定继续执行。 + * 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时), + * 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */ export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000; /** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */ export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000; diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index d924432fc..e94d7857e 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -198,6 +198,11 @@ export type SystemProxyConfig = { /** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */ export type ToolPolicy = "allow" | "ask" | "deny"; +/** 交互式应答(AskUserQuestion 提问卡 + 工具审批栏)的等待窗口,单位分钟。 + * 正数 = 超时窗口,超时后按各交互既定姿态落定(提问自动选推荐项并继续、 + * 审批按拒绝)。两处交互共用同一窗口。填很大的数(如 99999)≈ 永不超时。 */ +export const INTERACTIVE_TIMEOUT_DEFAULT_MINUTES = 3; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -207,6 +212,8 @@ export type SystemSettings = { * 可选:旧快照缺失该字段时视为空表(全部走默认),保证零回归。 */ toolPolicies?: Record; + /** 交互式应答超时(分钟):正数=窗口,超长≈永不。缺省 3,保持历史行为。 */ + interactiveTimeoutMinutes: number; workspaceProjects: WorkspaceProject[]; activeWorkspaceProjectId?: string; hiddenWorkspaceProjectPaths: string[]; @@ -1642,12 +1649,20 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig { }; } +/** 归一化交互式应答超时(分钟):正数=窗口(超长≈永不),缺省/非法/非正回 3。 */ +export function normalizeInteractiveTimeoutMinutes(input: unknown): number { + return typeof input === "number" && Number.isFinite(input) && input > 0 + ? input + : INTERACTIVE_TIMEOUT_DEFAULT_MINUTES; +} + export function normalizeSystemSettings(input: unknown): SystemSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; return { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + interactiveTimeoutMinutes: normalizeInteractiveTimeoutMinutes(obj.interactiveTimeoutMinutes), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), activeWorkspaceProjectId: typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim() @@ -2209,6 +2224,7 @@ export function getDefaultSettings(): AppSettings { missingWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [], systemProxy: getDefaultSystemProxyConfig(), + interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES, }, customProviders, mcp: { diff --git a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts index f365203b7..a47cfbbfa 100644 --- a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts +++ b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts @@ -46,6 +46,15 @@ function sweepStalePresetDeadlines(now: number) { } } +// 运行时应答窗口(毫秒):由设置 system.interactiveTimeoutMinutes 经 App.tsx 注入 +// (正数分钟换算而来;永不超时用很大的值表达)。未注入时回退默认常量。 +let configuredAskUserQuestionTimeoutMs = ASK_USER_QUESTION_TIMEOUT_MS; + +/** 由设置层注入运行时应答窗口(毫秒)。 */ +export function setAskUserQuestionTimeoutMs(timeoutMs: number): void { + configuredAskUserQuestionTimeoutMs = timeoutMs; +} + /** 网关侧上报工具参数时取(必要时预置)应答截止时间;挂起后与工具内计时同源。 */ export function ensureAskUserQuestionDeadlineAt(toolCallId: string): number { const trimmed = toolCallId.trim(); @@ -55,7 +64,7 @@ export function ensureAskUserQuestionDeadlineAt(toolCallId: string): number { sweepStalePresetDeadlines(now); const preset = presetDeadlineByToolCallId.get(trimmed); if (preset !== undefined) return preset; - const deadlineAt = now + ASK_USER_QUESTION_TIMEOUT_MS; + const deadlineAt = now + configuredAskUserQuestionTimeoutMs; presetDeadlineByToolCallId.set(trimmed, deadlineAt); return deadlineAt; } @@ -112,11 +121,13 @@ export function cancelPendingAskUserQuestionsForConversation(conversationId: str } } -const ASK_USER_QUESTION_TIMEOUT_MINUTES = Math.round(ASK_USER_QUESTION_TIMEOUT_MS / 60_000); +/** 工具描述按运行时窗口动态生成:写明具体分钟数与自动选推荐项的兜底语义。 + * 填很大的分钟数(≈永不超时)时,数字本身表明等待极久。 */ +function buildAskUserQuestionToolDescription(timeoutMs: number): string { + const minutes = Math.max(1, Math.round(timeoutMs / 60_000)); + return `Ask the user up to ${ASK_USER_QUESTION_MAX_QUESTIONS} multiple-choice questions and wait for their selections. Use this whenever you need a decision only the user can make: ambiguous requirements, mutually exclusive approaches, or trade-offs you cannot resolve from the conversation and the workspace. -const ASK_USER_QUESTION_TOOL_DESCRIPTION = `Ask the user up to ${ASK_USER_QUESTION_MAX_QUESTIONS} multiple-choice questions and wait for their selections. Use this whenever you need a decision only the user can make: ambiguous requirements, mutually exclusive approaches, or trade-offs you cannot resolve from the conversation and the workspace. - -The questions render as an interactive card; execution pauses until the user answers every question, then the selections come back as the tool result. If the user does not answer within ${ASK_USER_QUESTION_TIMEOUT_MINUTES} minutes, the recommended (or first) option of every question is auto-selected and execution continues — the result text tells you which happened. +The questions render as an interactive card; execution pauses until the user answers every question, then the selections come back as the tool result. If the user does not answer within ${minutes} minutes, the recommended (or first) option of every question is auto-selected and execution continues — the result text tells you which happened. Rules: - Ask 1-${ASK_USER_QUESTION_MAX_QUESTIONS} focused questions per call; each question needs ${ASK_USER_QUESTION_MIN_OPTIONS}-${ASK_USER_QUESTION_MAX_OPTIONS} options (3-4 is ideal), and every question in one call must have the SAME number of options. @@ -124,6 +135,7 @@ Rules: - The UI automatically appends an "Other" free-text option to every question, so the user can always type their own answer. Do NOT add your own catch-all option (e.g. "Other", "Custom", "其他", "自定义"). When the user types an answer, the result marks it as user-typed and returns their exact words instead of a listed label — treat it as authoritative. - Give each question a short header (2-6 chars works best) — it becomes the tab label when several questions show at once. - Do not use this for questions answerable from the code or the conversation, and never ask for confirmation of work you can safely do.`; +} const askUserQuestionParameters = Type.Object({ questions: Type.Array( @@ -171,13 +183,13 @@ function buildErrorResult(toolCall: ToolCall, text: string): ToolResultMessage { export function createAskUserQuestionTools(params: { conversationId: string; - /** 应答窗口毫秒数;仅测试注入,生产始终用默认值。 */ + /** 应答窗口毫秒数;仅测试注入,生产读运行时配置(setAskUserQuestionTimeoutMs)。 */ timeoutMs?: number; }): BuiltinToolBundle { - const timeoutMs = params.timeoutMs ?? ASK_USER_QUESTION_TIMEOUT_MS; + const timeoutMs = params.timeoutMs ?? configuredAskUserQuestionTimeoutMs; const toolAskUserQuestion: Tool = { name: ASK_USER_QUESTION_TOOL_NAME, - description: ASK_USER_QUESTION_TOOL_DESCRIPTION, + description: buildAskUserQuestionToolDescription(timeoutMs), parameters: askUserQuestionParameters, }; @@ -209,7 +221,7 @@ export function createAskUserQuestionTools(params: { // 测试注入 timeoutMs 时忽略预置,始终以注入值为准。 const presetDeadlineAt = presetDeadlineByToolCallId.get(toolCall.id); presetDeadlineByToolCallId.delete(toolCall.id); - const deadlineAt = + const deadlineAt: number = params.timeoutMs !== undefined || presetDeadlineAt === undefined ? Date.now() + timeoutMs : presetDeadlineAt; diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index 28bcf414a..78ae076d6 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -12,6 +12,15 @@ import { ASK_USER_QUESTION_TIMEOUT_MS } from "../chat/askUserQuestion"; /** 审批窗口毫秒数:复用 AskUserQuestion 的时长常量,行为口径一致。 */ export const TOOL_APPROVAL_TIMEOUT_MS = ASK_USER_QUESTION_TIMEOUT_MS; +// 运行时审批窗口(毫秒):由设置 system.interactiveTimeoutMinutes 经 App.tsx 注入 +// (与 AskUserQuestion 同一设置、同一窗口;永不超时用很大的值表达)。未注入回退默认常量。 +let configuredToolApprovalTimeoutMs = TOOL_APPROVAL_TIMEOUT_MS; + +/** 由设置层注入运行时审批窗口(毫秒)。 */ +export function setToolApprovalTimeoutMs(timeoutMs: number): void { + configuredToolApprovalTimeoutMs = timeoutMs; +} + /** approve:本次放行;deny:本次拒绝;approve_session:本会话内该工具后续免审。 */ export type ToolApprovalDecision = "approve" | "deny" | "approve_session"; @@ -155,7 +164,7 @@ export function requestToolApproval(params: { timeoutMs?: number; }): Promise { const toolCallId = params.toolCallId.trim(); - const timeoutMs = params.timeoutMs ?? TOOL_APPROVAL_TIMEOUT_MS; + const timeoutMs = params.timeoutMs ?? configuredToolApprovalTimeoutMs; const deadlineAt = Date.now() + timeoutMs; if (params.signal?.aborted) { diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index c81f90cdd..8599c29dd 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -5,9 +5,10 @@ // // 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁 //(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。 -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle"; import { Wrench } from "../../components/icons"; +import { Input } from "../../components/ui/input"; import { useLocale } from "../../i18n"; import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; @@ -51,6 +52,22 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + // 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免 + // 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。 + const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes; + const [timeoutDraft, setTimeoutDraft] = useState(null); + const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes); + const commitTimeoutDraft = () => { + if (timeoutDraft === null) return; + const parsed = Number.parseInt(timeoutDraft, 10); + if (!Number.isFinite(parsed)) { + setTimeoutDraft(null); + return; + } + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed })); + setTimeoutDraft(null); + }; + return (
@@ -70,6 +87,36 @@ export function SystemToolsSection(props: SettingsSectionProps) { ) : null}
+
+
+
+
{t("settings.interactiveTimeout.title")}
+

+ {t("settings.interactiveTimeout.desc")} +

+
+
+ setTimeoutDraft(event.currentTarget.value)} + onBlur={commitTimeoutDraft} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className="w-20" + /> + + {t("settings.interactiveTimeout.unit")} + +
+
+

+ {t("settings.interactiveTimeout.hint")} +

+
+
{groups.map(({ category, entries }) => (
diff --git a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs index 86056f407..c5f524567 100644 --- a/crates/agent-gui/test/chat/ask-user-question-card.test.mjs +++ b/crates/agent-gui/test/chat/ask-user-question-card.test.mjs @@ -236,10 +236,10 @@ test("a deadline already past at mount is distrusted and the pending card stays assert.equal(submitted[0][0].selectedLabel, "Second"); }); -test("a deadline beyond the full answer window is distrusted and clamps the countdown", () => { +test("a far-future deadline (long timeout window) is trusted and counts down to it", () => { const card = createCardHarness(); const tree = card.render({ - // 本机时钟慢于桌面盖章时钟:截止时间看似远超完整应答窗口。 + // 超长窗口(≈永不超时):截止时间远超默认窗口,仍被采信并显示其剩余时间。 deadlineAt: Date.now() + ASK_USER_QUESTION_TIMEOUT_MS + 5 * 60 * 1000, onSubmit: async () => ({ ok: true }), }); @@ -249,8 +249,7 @@ test("a deadline beyond the full answer window is distrusted and clamps the coun (node) => node.type === "button" && node.props?.role === "radio", ); assert.equal(optionButtons.every((button) => button.props.disabled === false), true); - // 倒计时按挂载近似显示完整窗口,而不是把偏移量当剩余时间。 - assert.match(treeText(tree), /(?:3:00|2:59) chat\.askUser\.timeoutHint/); + assert.match(treeText(tree), /(?:8:00|7:59) chat\.askUser\.timeoutHint/); }); test("a complete answer before the deadline submits the selected non-first option", async () => { diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 6396fc646..8926de5d5 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -2206,6 +2206,21 @@ test("close window behavior defaults to minimize and only accepts exit", () => { ); }); +test("interactive timeout minutes normalize to positive with default 3", () => { + // 默认 3 分钟,保持历史行为。 + assert.equal(settings.getDefaultSettings().system.interactiveTimeoutMinutes, 3); + assert.equal(settings.normalizeSystemSettings({}).interactiveTimeoutMinutes, 3); + + // 正数原样保留(无上限,超长≈永不超时);0/负数/非法回默认 3。 + assert.equal(settings.normalizeInteractiveTimeoutMinutes(5), 5); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(60), 60); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(99999), 99999); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(0), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(-5), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes(NaN), 3); + assert.equal(settings.normalizeInteractiveTimeoutMinutes("12"), 3); +}); + test("system proxy config normalizes defaults, ports, and password flags", () => { const defaults = settings.getDefaultSettings().system.systemProxy; assert.deepEqual(defaults, { diff --git a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs index e18246b2f..d196b52b9 100644 --- a/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs +++ b/crates/agent-gui/test/tools/ask-user-question-tools.test.mjs @@ -263,6 +263,33 @@ test("timeout falls back to the first option when no recommendation exists", asy assert.equal(tools.hasPendingAskUserQuestion("call-ask-first-fallback"), false); }); +test("a very long timeout window behaves like never within the test window", async () => { + const { tools } = loadModules(); + // 1 小时窗口:测试等待 ~60ms 内不会自动落定,等价于“永不”。 + const bundle = tools.createAskUserQuestionTools({ + conversationId: "conv-long-window", + timeoutMs: 60 * 60 * 1000, + }); + const toolCall = createToolCall(buildQuestionsArgs(), "call-ask-long-window"); + + const resultPromise = bundle.executeToolCall(toolCall); + const deadline = tools.getAskUserQuestionDeadlineAt("call-ask-long-window"); + assert.ok(Number.isFinite(deadline)); + assert.ok(deadline > Date.now()); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-long-window"), true); + + // 用户作答后才落定,且不标记超时。 + tools.answerAskUserQuestion("call-ask-long-window", [ + { questionId: "storage", selectedLabel: "应用数据目录" }, + { questionId: "q2", selectedLabel: "不迁移" }, + ]); + const result = await resultPromise; + assert.equal(result.isError, false); + assert.equal(result.details.timedOut, undefined); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-long-window"), false); +}); + test("immediate answers are pending synchronously and never fall through to timeout defaults", async () => { const { tools } = loadModules(); const bundle = tools.createAskUserQuestionTools({ conversationId: "conv-fast", timeoutMs: 100 }); diff --git a/crates/agent-gui/test/tools/tool-approval.test.mjs b/crates/agent-gui/test/tools/tool-approval.test.mjs index 75996aa9a..bd0e36e0f 100644 --- a/crates/agent-gui/test/tools/tool-approval.test.mjs +++ b/crates/agent-gui/test/tools/tool-approval.test.mjs @@ -64,6 +64,25 @@ test("超时落定为 timeout", async () => { assert.equal(hasPendingToolApproval("c4"), false); }); +test("很长的超时窗口 ≈ 永不:挂起等待用户决定,不在测试窗口内自动落定", async () => { + const promise = requestToolApproval({ + toolCallId: "c-long-window", + toolName: "Bash", + conversationId: "conv-long-window", + timeoutMs: 60 * 60 * 1000, // 1 小时 + }); + assert.equal(hasPendingToolApproval("c-long-window"), true); + const pending = getPendingToolApproval("c-long-window"); + assert.ok(pending && pending.deadlineAt > Date.now() + 60 * 60 * 1000 - 60_000); + // 等一拍,确认不会自动 timeout。 + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(hasPendingToolApproval("c-long-window"), true); + // 用户决定后才落定。 + answerToolApproval("c-long-window", "approve"); + assert.deepEqual(await promise, { kind: "decided", decision: "approve" }); + assert.equal(hasPendingToolApproval("c-long-window"), false); +}); + test("AbortSignal 触发 → cancelled;已 aborted 的信号立即 cancelled", async () => { const controller = new AbortController(); const promise = requestToolApproval({ diff --git a/docs/features/chat-runtime.md b/docs/features/chat-runtime.md index 4a964f538..f093c4429 100644 --- a/docs/features/chat-runtime.md +++ b/docs/features/chat-runtime.md @@ -82,7 +82,7 @@ Hooks 支持 shell script 和 HTTP requests,设置由 GUI/WebUI 同步维护 | 能力 | 语义 | |---|---| | 工具形态 | chat-only 内置工具;模型一次最多提 4 个问题,每题 2-6 个选项且**同轮各题选项数一致**,至多一个"推荐"项且**固定排在首位**。 | -| 挂起语义 | `execute` 在工具挂起表(toolCallId 键)上等待;用户提交后 resolve,停止按钮经 AbortSignal 以"未应答"落定(`details.cancelled`);**3 分钟未作答按推荐项(缺省第一项)自动落定**(`details.timedOut`),卡片展示倒计时。**倒计时双端同源**:桌面端在网关上报的工具参数上盖 `__askUserQuestionDeadlineAt` 权威截止时间戳(`gatewayToolPreview` 统一盖章,execute 复用同一预置值),WebUI/重连场景按真实剩余时间倒数。 | +| 挂起语义 | `execute` 在工具挂起表(toolCallId 键)上等待;用户提交后 resolve,停止按钮经 AbortSignal 以"未应答"落定(`details.cancelled`);**应答窗口由设置 `system.interactiveTimeoutMinutes` 配置(正数分钟,默认 3,与工具审批栏共用同一窗口;填很大的数如 99999 ≈ 永不超时);超时后按推荐项(缺省第一项)自动落定**(`details.timedOut`),卡片展示倒计时。**倒计时双端同源**:桌面端在网关上报的工具参数上盖 `__askUserQuestionDeadlineAt` 权威截止时间戳(`gatewayToolPreview` 统一盖章,execute 复用同一预置值),WebUI/重连场景按真实剩余时间倒数。 | | 卡片 UI | `components/chat/AskUserQuestionCard.tsx`(双端镜像):多问题以顶部 tabs 切换,单选 + 推荐标记,全部作答后提交;应答落定后只读回显。**问题与选项全部生成完毕后整卡出现**(`runAgentConversationTurn` 跳过 AskUserQuestion 的 tool_call_delta,双端不做流式渐显)。 | | 双端应答 | GUI 直接调用 `answerAskUserQuestion`;WebUI 走 `chat_queue.tool_answer`(item_id=toolCallId,request_json=选择数组)由桌面端落到同一挂起表,协议零改动;远端应答**校验 conversation_id 与挂起提问所属会话一致**,防串会话应答。 | | 结果回模型 | 标准 `ToolResultMessage`:content 列出每题的最终选择,`details.kind = "ask_user_question"` 驱动历史回放渲染。 |