diff --git a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx index d7ebc2ef4..38d5b3473 100644 --- a/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gateway/web/src/components/chat/AskUserQuestionCard.tsx @@ -10,6 +10,7 @@ import { ASK_USER_QUESTION_TIMEOUT_MS, type AskUserQuestionAnswer, type AskUserQuestionItem, + INTERACTIVE_TIMEOUT_MAX_MINUTES, } from "../../lib/chat/askUserQuestion"; import { cn } from "../../lib/shared/utils"; import { Check, Sparkles } from "../icons"; @@ -25,22 +26,21 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。应答窗口可由设置调到很长 + * (≈永不超时),因此上界按「可配置的最大窗口」判定而不是默认窗口;截止时间 + * 缺失/已过期/明显不可比时回退为挂载时刻近似,避免把可作答的卡片锁死。 + * 倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期(或显示远超任何合法窗口的剩余时间)。 + * 因此仅采信落在“挂载时刻(不含)~挂载时刻 + 最大可配置窗口(含)”内的截止 + * 时间,否则视为时钟不可比;真正过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); + const maxTrustedDeadline = mountedAt + INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt && deadlineAt <= maxTrustedDeadline ? 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..d3c30dd57 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1244,6 +1244,8 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.unit": "分钟", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -1316,7 +1318,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": - "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,3 分钟内未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", + "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,超时时间可用下方滑块调整,超时后未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", "settings.builtinTool.cron_task_manager.name": "定时任务", "settings.builtinTool.cron_task_manager.desc": "创建与管理定时自动任务", "settings.builtinTool.cron_task_manager.detail": @@ -3466,6 +3468,8 @@ 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.unit": "min", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", @@ -3540,7 +3544,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.desc": "Ask you multiple-choice questions in a card and wait for your selections", "settings.builtinTool.ask_user_question.detail": - "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; after 3 minutes without an answer the recommended options are auto-selected, and pressing Stop skips the question. Chat sessions only.", + "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; the answer window is adjustable with the slider below, and when it elapses without an answer the recommended options are auto-selected. Pressing Stop skips the question. Chat sessions only.", "settings.builtinTool.cron_task_manager.name": "Scheduled Tasks", "settings.builtinTool.cron_task_manager.desc": "Create and manage scheduled automations", "settings.builtinTool.cron_task_manager.detail": diff --git a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts index 38fdf437e..e0d01bed7 100644 --- a/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts @@ -7,8 +7,50 @@ 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; +/** 交互式应答超时(分钟)的默认值与上限,与桌面端 Rust 归一化同口径。 + * 上限存在的意义不是规避 setTimeout 溢出(那由分段续期解决), + * 而是避免手改配置写入荒谬值。 */ +export const INTERACTIVE_TIMEOUT_MIN_MINUTES = 1; +export const INTERACTIVE_TIMEOUT_MAX_MINUTES = 99_999; +/** setTimeout 的延迟形参是 IDL long(32 位有符号),超过 2^31-1 会被 ToInt32 + * 回绕:99999 分钟被截成约 19.7 天,而 35792~200000 分钟之间的值回绕成负数后 + * 被钳为 0,导致「永不超时」瞬间立刻超时。故长延迟必须分段续期。 */ +export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; + +/** + * 分段续期的长延迟定时器:单次 setTimeout 只排最多 MAX_SAFE_TIMEOUT_DELAY_MS, + * 到点后按剩余时间继续排,直到真正跨过 deadline 才触发 onDeadline。返回取消函数。 + * + * 与裸 setTimeout 一致,回调恒异步触发(deadline 已过也先让出一轮), + * 这样调用方仍可在同一同步块里先拿到取消函数再在回调中引用它。 + */ +export function scheduleAtDeadline(deadlineAt: number, onDeadline: () => void): () => void { + let timerId: ReturnType | undefined; + let cancelled = false; + const arm = () => { + if (cancelled) return; + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + onDeadline(); + return; + } + timerId = setTimeout(arm, Math.min(remainingMs, MAX_SAFE_TIMEOUT_DELAY_MS)); + }; + // 首拍也走 setTimeout,保证回调不会在本函数返回前同步触发。 + timerId = setTimeout( + arm, + Math.min(Math.max(0, deadlineAt - Date.now()), MAX_SAFE_TIMEOUT_DELAY_MS), + ); + return () => { + cancelled = true; + if (timerId !== undefined) clearTimeout(timerId); + }; +} + /** 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..435ab339a 100644 --- a/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx @@ -13,6 +13,9 @@ import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; +// 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 +const TIMEOUT_STOPS = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999]; + export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); @@ -51,6 +54,19 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + const timeoutMinutes = settings.system.interactiveTimeoutMinutes; + const timeoutStopIndex = TIMEOUT_STOPS.reduce( + (best, stop, i) => + Math.abs(stop - timeoutMinutes) < Math.abs(TIMEOUT_STOPS[best] - timeoutMinutes) ? i : best, + 0, + ); + const onTimeoutStopChange = (index: number) => { + const next = TIMEOUT_STOPS[index]; + if (next !== timeoutMinutes) { + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: next })); + } + }; + return (
@@ -80,7 +96,7 @@ export function SystemToolsSection(props: SettingsSectionProps) { {entries.map((entry) => { const policy = effectivePolicy(entry.toolName, entry.isReadOnly); return ( -
+
@@ -99,7 +115,26 @@ export function SystemToolsSection(props: SettingsSectionProps) { {t(`settings.builtinTool.${entry.id}.desc`)}
- {entry.isReadOnly ? ( + {entry.isReadOnly && entry.id === "ask_user_question" ? ( +
+ + onTimeoutStopChange(Number(event.currentTarget.value)) + } + className="w-full accent-primary" + /> +
+ {timeoutMinutes}{" "} + {t("settings.interactiveTimeout.unit")} +
+
+ ) : entry.isReadOnly ? ( {t("settings.toolPolicy.allow")} diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index ecf34f264..d1bc6a7dc 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -37,6 +37,14 @@ const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectP const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths"; const SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY: &str = "archivedWorkspaceProjectPaths"; const SYSTEM_SYSTEM_PROXY_KEY: &str = "systemProxy"; +// 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用的等待窗口。 +// 与 toolPolicies 同理必须进保存白名单,否则前端改完滑块重启即回默认值。 +const SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY: &str = "interactiveTimeoutMinutes"; +/// 交互式应答超时的默认值与上限(分钟)。上限与前端档位表最大档一致: +/// 分钟数换算成毫秒后由前端分段续期计时,不再受 setTimeout 32 位延迟上限影响, +/// 但仍保留上限以免手改配置写入荒谬值(并与前端归一化口径保持一致)。 +const DEFAULT_INTERACTIVE_TIMEOUT_MINUTES: f64 = 3.0; +const MAX_INTERACTIVE_TIMEOUT_MINUTES: f64 = 99_999.0; const DEFAULT_WORKSPACE_PROJECT_ID: &str = "default-project"; const DEFAULT_WORKSPACE_PROJECT_NAME: &str = "Default Project"; pub(crate) const PROVIDER_API_KEY_UPDATES_FIELD: &str = "providerApiKeyUpdates"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 6653c5226..a7e7b6199 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -183,6 +183,20 @@ fn normalize_archived_workspace_project_paths(raw: Option<&Value>) -> Value { Value::Array(out) } +/// 归一化交互式应答超时(分钟):正数原样保留、上限 99999(≈永不超时), +/// 缺省/非法/非正回默认 3。与前端 normalizeInteractiveTimeoutMinutes 同口径, +/// 保证桌面端落库值与 GUI/WebUI 归一化结果一致。 +fn normalize_interactive_timeout_minutes_value(raw: Option<&Value>) -> Value { + let minutes = raw + .and_then(Value::as_f64) + .filter(|minutes| minutes.is_finite() && *minutes > 0.0) + .map(|minutes| minutes.min(MAX_INTERACTIVE_TIMEOUT_MINUTES)) + .unwrap_or(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES); + Number::from_f64(minutes) + .map(Value::Number) + .unwrap_or_else(|| json!(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES)) +} + fn normalize_system_proxy_value(raw: Option<&Value>) -> Value { let obj = match raw { Some(Value::Object(map)) => map.clone(), @@ -340,6 +354,12 @@ fn system_value_with_defaults(raw: Option, default_workdir: &str) -> Valu SYSTEM_SYSTEM_PROXY_KEY.to_string(), normalize_system_proxy_value(system.get(SYSTEM_SYSTEM_PROXY_KEY)), ); + system.insert( + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY.to_string(), + normalize_interactive_timeout_minutes_value( + system.get(SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY), + ), + ); Value::Object(system) } @@ -387,6 +407,7 @@ fn save_system_with_default_workdir( SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY, SYSTEM_SYSTEM_PROXY_KEY, + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY, ] { let value = system.get(key).cloned().unwrap_or(Value::Null); tx.execute( diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index 4793f7443..fe5fb0109 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1120,7 +1120,7 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 9); + assert_eq!(row_count, 10); assert_eq!( keys, vec![ @@ -1128,6 +1128,7 @@ mod tests { SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_EXECUTION_MODE_KEY.to_string(), SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY.to_string(), + SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY.to_string(), SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_SYSTEM_PROXY_KEY.to_string(), SYSTEM_TOOL_POLICIES_KEY.to_string(), @@ -1144,6 +1145,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": default_workdir.clone(), "toolPolicies": { "Bash": "ask", "server:docs-mcp": "deny" }, "workspaceProjects": [ @@ -1188,6 +1190,46 @@ mod tests { ); } + /// 交互式应答超时必须真正落库:该键此前不在保存白名单里,前端调完滑块 + /// 重启即回默认值,功能等于没生效。顺带覆盖归一化(非正/非法回默认、 + /// 超上限钳制),与前端 normalizeInteractiveTimeoutMinutes 同口径。 + #[test] + fn save_system_round_trips_interactive_timeout_minutes() { + let cases = [ + (json!(30), json!(30.0)), + (json!(99999), json!(99999.0)), + // 超上限钳制到 99999;非正/非法/缺省回默认 3。 + (json!(200000), json!(99999.0)), + (json!(0), json!(3.0)), + (json!(-5), json!(3.0)), + (json!("12"), json!(3.0)), + (Value::Null, json!(3.0)), + ]; + + for (input, expected) in cases { + let mut conn = open_memory_db(); + save_system_with_default_workdir( + &mut conn, + json!({ + "executionMode": "tools", + "workdir": "/tmp/liveagent-default-project", + "interactiveTimeoutMinutes": input, + }), + "/tmp/liveagent-default-project", + ) + .expect("save system"); + + let loaded = load_system(&conn) + .expect("load system") + .expect("system settings"); + assert_eq!( + loaded.get(SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY), + Some(&expected), + "interactiveTimeoutMinutes must survive the save/load round trip" + ); + } + } + #[test] fn save_system_backfills_empty_workdir_with_default_project() { let mut conn = open_memory_db(); @@ -1211,6 +1253,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, "workspaceProjects": [ @@ -1262,6 +1305,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, "workspaceProjects": [ @@ -1295,6 +1339,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "systemProxy": default_system_proxy_json(), + "interactiveTimeoutMinutes": 3.0, "workdir": "/tmp/liveagent-default-project", "workspaceProjects": [ { 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..38d5b3473 100644 --- a/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx +++ b/crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx @@ -10,6 +10,7 @@ import { ASK_USER_QUESTION_TIMEOUT_MS, type AskUserQuestionAnswer, type AskUserQuestionItem, + INTERACTIVE_TIMEOUT_MAX_MINUTES, } from "../../lib/chat/askUserQuestion"; import { cn } from "../../lib/shared/utils"; import { Check, Sparkles } from "../icons"; @@ -25,22 +26,21 @@ function formatCountdown(remainingMs: number) { /** * 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读 - * 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据) - * 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片 - * 切到只读态。 + * 网关参数上的 deadline 盖章),两端与桌面计时同源。应答窗口可由设置调到很长 + * (≈永不超时),因此上界按「可配置的最大窗口」判定而不是默认窗口;截止时间 + * 缺失/已过期/明显不可比时回退为挂载时刻近似,避免把可作答的卡片锁死。 + * 倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。 * - * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时, - * 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当 - * 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信, - * 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的 - * 提交仍由桌面挂起表权威拒绝。 + * 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个 + * 仍在挂起的提问会在挂载瞬间显示过期(或显示远超任何合法窗口的剩余时间)。 + * 因此仅采信落在“挂载时刻(不含)~挂载时刻 + 最大可配置窗口(含)”内的截止 + * 时间,否则视为时钟不可比;真正过期的提交仍由桌面挂起表权威拒绝。 */ function useAnswerCountdown(active: boolean, deadlineAt?: number) { const [mountedAt] = useState(() => Date.now()); + const maxTrustedDeadline = mountedAt + INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; const deadline = - deadlineAt !== undefined && - deadlineAt > mountedAt && - deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS + deadlineAt !== undefined && deadlineAt > mountedAt && deadlineAt <= maxTrustedDeadline ? 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..1a91e3e5f 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1314,6 +1314,8 @@ export const translations: Record> = { "settings.systemTools": "系统工具", "settings.systemToolsDesc": "查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。", + "settings.interactiveTimeout.title": "交互式应答超时", + "settings.interactiveTimeout.unit": "分钟", "settings.builtinToolCategory.fs": "文件系统", "settings.builtinToolCategory.process": "终端与进程", "settings.builtinToolCategory.intelligence": "智能与记忆", @@ -1386,7 +1388,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.name": "用户提问", "settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择", "settings.builtinTool.ask_user_question.detail": - "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,3 分钟内未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", + "模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,超时时间可用下方滑块调整,超时后未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。", "settings.builtinTool.cron_task_manager.name": "定时任务", "settings.builtinTool.cron_task_manager.desc": "创建与管理定时自动任务", "settings.builtinTool.cron_task_manager.detail": @@ -3629,6 +3631,8 @@ 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.unit": "min", "settings.builtinToolCategory.fs": "File System", "settings.builtinToolCategory.process": "Terminal & Processes", "settings.builtinToolCategory.intelligence": "Intelligence & Memory", @@ -3703,7 +3707,7 @@ export const translations: Record> = { "settings.builtinTool.ask_user_question.desc": "Ask you multiple-choice questions in a card and wait for your selections", "settings.builtinTool.ask_user_question.detail": - "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; after 3 minutes without an answer the recommended options are auto-selected, and pressing Stop skips the question. Chat sessions only.", + "Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; the answer window is adjustable with the slider below, and when it elapses without an answer the recommended options are auto-selected. Pressing Stop skips the question. Chat sessions only.", "settings.builtinTool.cron_task_manager.name": "Scheduled Tasks", "settings.builtinTool.cron_task_manager.desc": "Create and manage scheduled automations", "settings.builtinTool.cron_task_manager.detail": diff --git a/crates/agent-gui/src/lib/chat/askUserQuestion.ts b/crates/agent-gui/src/lib/chat/askUserQuestion.ts index 38fdf437e..e0d01bed7 100644 --- a/crates/agent-gui/src/lib/chat/askUserQuestion.ts +++ b/crates/agent-gui/src/lib/chat/askUserQuestion.ts @@ -7,8 +7,50 @@ 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; +/** 交互式应答超时(分钟)的默认值与上限,与桌面端 Rust 归一化同口径。 + * 上限存在的意义不是规避 setTimeout 溢出(那由分段续期解决), + * 而是避免手改配置写入荒谬值。 */ +export const INTERACTIVE_TIMEOUT_MIN_MINUTES = 1; +export const INTERACTIVE_TIMEOUT_MAX_MINUTES = 99_999; +/** setTimeout 的延迟形参是 IDL long(32 位有符号),超过 2^31-1 会被 ToInt32 + * 回绕:99999 分钟被截成约 19.7 天,而 35792~200000 分钟之间的值回绕成负数后 + * 被钳为 0,导致「永不超时」瞬间立刻超时。故长延迟必须分段续期。 */ +export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; + +/** + * 分段续期的长延迟定时器:单次 setTimeout 只排最多 MAX_SAFE_TIMEOUT_DELAY_MS, + * 到点后按剩余时间继续排,直到真正跨过 deadline 才触发 onDeadline。返回取消函数。 + * + * 与裸 setTimeout 一致,回调恒异步触发(deadline 已过也先让出一轮), + * 这样调用方仍可在同一同步块里先拿到取消函数再在回调中引用它。 + */ +export function scheduleAtDeadline(deadlineAt: number, onDeadline: () => void): () => void { + let timerId: ReturnType | undefined; + let cancelled = false; + const arm = () => { + if (cancelled) return; + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + onDeadline(); + return; + } + timerId = setTimeout(arm, Math.min(remainingMs, MAX_SAFE_TIMEOUT_DELAY_MS)); + }; + // 首拍也走 setTimeout,保证回调不会在本函数返回前同步触发。 + timerId = setTimeout( + arm, + Math.min(Math.max(0, deadlineAt - Date.now()), MAX_SAFE_TIMEOUT_DELAY_MS), + ); + return () => { + cancelled = true; + if (timerId !== undefined) clearTimeout(timerId); + }; +} + /** 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..a6377af99 100644 --- a/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts +++ b/crates/agent-gui/src/lib/tools/askUserQuestionTools.ts @@ -13,6 +13,7 @@ import { buildDefaultAskUserQuestionAnswers, parseAskUserQuestionItems, resolveAskUserQuestionAnswers, + scheduleAtDeadline, } from "../chat/askUserQuestion"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; @@ -46,6 +47,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 +65,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 +122,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 +136,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 +184,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 +222,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; @@ -217,13 +230,13 @@ export function createAskUserQuestionTools(params: { const settle = (value: AskUserQuestionSettlement) => { pendingByToolCallId.delete(toolCall.id); signal?.removeEventListener("abort", onAbort); - clearTimeout(timeoutId); + cancelTimeout(); resolve(value); }; const onAbort = () => settle({ kind: "cancelled" }); - const timeoutId = setTimeout( - () => settle({ kind: "timeout", answers: buildDefaultAskUserQuestionAnswers(questions) }), - Math.max(0, deadlineAt - Date.now()), + // 分段续期计时:超长窗口(≈永不超时)不会被 setTimeout 的 32 位延迟上限回绕。 + const cancelTimeout = scheduleAtDeadline(deadlineAt, () => + settle({ kind: "timeout", answers: buildDefaultAskUserQuestionAnswers(questions) }), ); pendingByToolCallId.set(toolCall.id, { conversationId: params.conversationId, diff --git a/crates/agent-gui/src/lib/tools/toolApproval.ts b/crates/agent-gui/src/lib/tools/toolApproval.ts index 28bcf414a..b9864890f 100644 --- a/crates/agent-gui/src/lib/tools/toolApproval.ts +++ b/crates/agent-gui/src/lib/tools/toolApproval.ts @@ -7,11 +7,20 @@ // 远端(WebUI)应答经 gateway chat_queue.tool_approval 转发到桌面后走同一入口 // answerToolApproval(第 3 步接线)。 -import { ASK_USER_QUESTION_TIMEOUT_MS } from "../chat/askUserQuestion"; +import { ASK_USER_QUESTION_TIMEOUT_MS, scheduleAtDeadline } 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) { @@ -169,7 +178,7 @@ export function requestToolApproval(params: { pendingByToolCallId.delete(toolCallId); } params.signal?.removeEventListener("abort", onAbort); - clearTimeout(timeoutId); + cancelTimeout(); if (settlement.kind === "decided" && settlement.decision === "approve_session") { rememberSessionApproval(params.conversationId, params.toolName); } @@ -177,7 +186,9 @@ export function requestToolApproval(params: { resolve(settlement); }; const onAbort = () => settle({ kind: "cancelled" }); - const timeoutId = setTimeout(() => settle({ kind: "timeout" }), Math.max(0, timeoutMs)); + // 分段续期定时器:审批窗口可配到远超 setTimeout 的 32 位延迟上限, + // 直接传大延迟会被静默截断(甚至立即触发)。见 scheduleAtDeadline。 + const cancelTimeout = scheduleAtDeadline(deadlineAt, () => settle({ kind: "timeout" })); const pending: PendingToolApproval = { conversationId: params.conversationId, toolName: params.toolName, diff --git a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx index c81f90cdd..435ab339a 100644 --- a/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx +++ b/crates/agent-gui/src/pages/settings/SystemToolsSection.tsx @@ -13,6 +13,9 @@ import { type ToolPolicy, updateSystem } from "../../lib/settings"; import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog"; import type { SettingsSectionProps } from "./types"; +// 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。 +const TIMEOUT_STOPS = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999]; + export function SystemToolsSection(props: SettingsSectionProps) { const { settings, setSettings } = props; const { t } = useLocale(); @@ -51,6 +54,19 @@ export function SystemToolsSection(props: SettingsSectionProps) { const overriddenCount = Object.keys(policies).length; + const timeoutMinutes = settings.system.interactiveTimeoutMinutes; + const timeoutStopIndex = TIMEOUT_STOPS.reduce( + (best, stop, i) => + Math.abs(stop - timeoutMinutes) < Math.abs(TIMEOUT_STOPS[best] - timeoutMinutes) ? i : best, + 0, + ); + const onTimeoutStopChange = (index: number) => { + const next = TIMEOUT_STOPS[index]; + if (next !== timeoutMinutes) { + setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: next })); + } + }; + return (
@@ -80,7 +96,7 @@ export function SystemToolsSection(props: SettingsSectionProps) { {entries.map((entry) => { const policy = effectivePolicy(entry.toolName, entry.isReadOnly); return ( -
+
@@ -99,7 +115,26 @@ export function SystemToolsSection(props: SettingsSectionProps) { {t(`settings.builtinTool.${entry.id}.desc`)}
- {entry.isReadOnly ? ( + {entry.isReadOnly && entry.id === "ask_user_question" ? ( +
+ + onTimeoutStopChange(Number(event.currentTarget.value)) + } + className="w-full accent-primary" + /> +
+ {timeoutMinutes}{" "} + {t("settings.interactiveTimeout.unit")} +
+
+ ) : entry.isReadOnly ? ( {t("settings.toolPolicy.allow")} 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..ae1e6d5b0 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 @@ -8,9 +8,8 @@ const i18nPath = fileURLToPath(new URL("../../src/i18n/index.ts", import.meta.ur const iconsPath = fileURLToPath(new URL("../../src/components/icons/index.ts", import.meta.url)); const utilsPath = fileURLToPath(new URL("../../src/lib/shared/utils.ts", import.meta.url)); -const { ASK_USER_QUESTION_TIMEOUT_MS } = createTsModuleLoader().loadModule( - "src/lib/chat/askUserQuestion.ts", -); +const { ASK_USER_QUESTION_TIMEOUT_MS, INTERACTIVE_TIMEOUT_MAX_MINUTES } = + createTsModuleLoader().loadModule("src/lib/chat/askUserQuestion.ts"); const questions = [ { @@ -236,10 +235,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,7 +248,24 @@ 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), /(?:8:00|7:59) chat\.askUser\.timeoutHint/); +}); + +test("a deadline beyond the largest configurable window is distrusted as clock skew", () => { + const card = createCardHarness(); + const tree = card.render({ + // 本机时钟慢于桌面盖章时钟:截止时间超出「最大可配置窗口」,视为时钟不可比。 + // 上界随配置上限而非默认窗口,长窗口(≈永不超时)本身仍被采信(见上一条)。 + deadlineAt: Date.now() + (INTERACTIVE_TIMEOUT_MAX_MINUTES + 1) * 60_000, + onSubmit: async () => ({ ok: true }), + }); + + const optionButtons = findAll( + tree, + (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/); }); 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..fb4e73f83 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,108 @@ 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); +}); + +// 回归:最大档 99999 分钟换算成毫秒后超过 setTimeout 的 32 位延迟上限, +// 裸 setTimeout 会 ToInt32 回绕(99999 分钟被截成约 19.7 天,35792~200000 +// 分钟之间更会回绕成负数并被钳为 0 → 「永不超时」瞬间立刻超时)。 +// scheduleAtDeadline 分段续期后,deadline 必须仍是完整窗口且不会提前落定。 +test("the largest configurable window is not truncated by the 32-bit setTimeout limit", async () => { + const { shared, tools } = loadModules(); + const timeoutMs = shared.INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000; + assert.ok(timeoutMs > shared.MAX_SAFE_TIMEOUT_DELAY_MS, "该用例必须跨过 32 位上限才有意义"); + + const bundle = tools.createAskUserQuestionTools({ + conversationId: "conv-max-window", + timeoutMs, + }); + const toolCall = createToolCall(buildQuestionsArgs(), "call-ask-max-window"); + + const startedAt = Date.now(); + const resultPromise = bundle.executeToolCall(toolCall); + const deadline = tools.getAskUserQuestionDeadlineAt("call-ask-max-window"); + // deadline 记的是完整窗口,而不是回绕后的约 19.7 天。 + assert.ok(deadline >= startedAt + timeoutMs - 1000, "deadline 必须是完整窗口"); + + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(tools.hasPendingAskUserQuestion("call-ask-max-window"), true); + + tools.answerAskUserQuestion("call-ask-max-window", [ + { questionId: "storage", selectedLabel: "应用数据目录" }, + { questionId: "q2", selectedLabel: "不迁移" }, + ]); + const result = await resultPromise; + assert.equal(result.isError, false); + assert.equal(result.details.timedOut, undefined); +}); + +// scheduleAtDeadline 自身语义:分段续期、可取消、回调恒异步。 +test("scheduleAtDeadline caps each hop, stays cancellable, and fires asynchronously", async () => { + const { shared } = loadModules(); + + // 1) 已过期的 deadline 也不同步触发(调用方要能先拿到取消函数)。 + let firedSync = true; + const cancelPast = shared.scheduleAtDeadline(Date.now() - 10_000, () => { + firedSync = false; + }); + assert.equal(firedSync, true, "回调不得在 scheduleAtDeadline 返回前同步触发"); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(firedSync, false, "已过期的 deadline 应在下一拍触发"); + cancelPast(); + + // 2) 短 deadline 正常触发。 + const firedAt = await new Promise((resolve) => { + const t0 = Date.now(); + shared.scheduleAtDeadline(t0 + 30, () => resolve(Date.now() - t0)); + }); + assert.ok(firedAt >= 25, "应等到 deadline 才触发,实际 " + firedAt + "ms"); + + // 3) 取消后不再触发。 + let cancelledFired = false; + const cancel = shared.scheduleAtDeadline(Date.now() + 20, () => { + cancelledFired = true; + }); + cancel(); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(cancelledFired, false, "取消后不得触发"); + + // 4) 超长 deadline 不会因回绕而立即触发(裸 setTimeout 在此会立刻落定)。 + const overflowMinutes = 40_000; // ToInt32 后为负数的区间 + assert.ok(((overflowMinutes * 60_000) | 0) < 0, "该分钟数必须落在回绕成负数的区间"); + let overflowFired = false; + const cancelOverflow = shared.scheduleAtDeadline(Date.now() + overflowMinutes * 60_000, () => { + overflowFired = true; + }); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(overflowFired, false, "超长窗口不得因 32 位回绕而立即触发"); + cancelOverflow(); +}); + 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..db75fc31e 100644 --- a/crates/agent-gui/test/tools/tool-approval.test.mjs +++ b/crates/agent-gui/test/tools/tool-approval.test.mjs @@ -64,6 +64,47 @@ 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); +}); + +// 回归:最大档 99999 分钟换算成毫秒后远超 setTimeout 的 32 位延迟上限, +// 直接传入会被 ToInt32 回绕(35792~200000 分钟区间甚至回绕成负数→立即触发), +// 「永不超时」会瞬间变成秒拒。分段续期后不得在测试窗口内落定。 +test("最大档窗口不被 setTimeout 32 位延迟上限回绕成立即超时", async () => { + const promise = requestToolApproval({ + toolCallId: "c-max-window", + toolName: "Bash", + conversationId: "conv-max-window", + timeoutMs: 99_999 * 60_000, + }); + assert.equal(hasPendingToolApproval("c-max-window"), true); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal( + hasPendingToolApproval("c-max-window"), + true, + "最大档窗口不得因延迟回绕而立即落定为 timeout", + ); + answerToolApproval("c-max-window", "approve"); + assert.deepEqual(await promise, { kind: "decided", decision: "approve" }); + assert.equal(hasPendingToolApproval("c-max-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"` 驱动历史回放渲染。 | diff --git a/docs/images/pr386-interactive-timeout.png b/docs/images/pr386-interactive-timeout.png new file mode 100644 index 000000000..9035581d9 Binary files /dev/null and b/docs/images/pr386-interactive-timeout.png differ