From 85e6a6ed065126a9ac2056e6179ce406e6c63dc4 Mon Sep 17 00:00:00 2001 From: hongyu <2379713353@qq.com> Date: Wed, 3 Jun 2026 16:11:57 +0800 Subject: [PATCH 1/4] feat(desktop): support adaptive tool timeouts --- .../services/AgentService/catalog/tools.ts | 34 +++++++++++++-- .../AgentService/execution/executor.ts | 43 +++++++++++++++++-- .../infrastructure/mcp/McpManager.ts | 11 ++++- .../services/BuiltInToolService/service.ts | 2 + .../BuiltInToolService/tools/bash/index.ts | 9 +++- .../src/services/BuiltInToolService/types.ts | 1 + 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/services/AgentService/catalog/tools.ts b/apps/desktop/src/services/AgentService/catalog/tools.ts index 583f980d..4289458c 100644 --- a/apps/desktop/src/services/AgentService/catalog/tools.ts +++ b/apps/desktop/src/services/AgentService/catalog/tools.ts @@ -11,6 +11,31 @@ export interface ResolveToolDefinitionsOptions { excludedToolNames?: string[]; } +const TOOL_TIMEOUT_META_PROPERTY = { + type: 'object', + description: 'Optional execution metadata for this tool call.', + properties: { + timeoutMs: { + type: 'integer', + description: + 'Optional timeout in milliseconds. Use only when a task is expected to take longer or shorter than the default. Maximum: 600000 (10 minutes).', + }, + }, +} as const; + +function withAdaptiveTimeoutSchema(tool: AiToolDefinition): AiToolDefinition { + return { + ...tool, + input_schema: { + ...tool.input_schema, + properties: { + ...tool.input_schema.properties, + _meta: TOOL_TIMEOUT_META_PROPERTY, + }, + }, + }; +} + /** * 解析当前模型可用的工具定义列表。 */ @@ -27,10 +52,11 @@ export async function resolveToolDefinitions( builtInToolService.getEnabledToolDefinitions(), ]); const allTools = [...mcpTools, ...builtInTools]; - if (!options.excludedToolNames?.length) { - return allTools; + let filteredTools = allTools; + if (options.excludedToolNames?.length) { + const excludedToolNames = new Set(options.excludedToolNames); + filteredTools = allTools.filter((tool) => !excludedToolNames.has(tool.name)); } - const excludedToolNames = new Set(options.excludedToolNames); - return allTools.filter((tool) => !excludedToolNames.has(tool.name)); + return filteredTools.map(withAdaptiveTimeoutSchema); } diff --git a/apps/desktop/src/services/AgentService/execution/executor.ts b/apps/desktop/src/services/AgentService/execution/executor.ts index bab100ab..203d82b3 100644 --- a/apps/desktop/src/services/AgentService/execution/executor.ts +++ b/apps/desktop/src/services/AgentService/execution/executor.ts @@ -43,6 +43,7 @@ const BUILT_IN_UPGRADE_TOOL_NAME = 'builtin__upgrade_model'; const MAX_REQUEST_MODEL_SWITCHES = 4; const MODEL_SWITCH_EXCLUDED_TOOL_NAMES = [BUILT_IN_UPGRADE_TOOL_NAME]; const toolArgumentsSchema = z.record(z.string(), z.unknown()); +const TOOL_TIMEOUT_META_KEY = '_meta'; /** * 这些文本会进入工具结果和会话历史,属于用户可见内容,因此统一使用中文。 * console 日志仍保留英文,便于对齐 SDK、provider 和协议层排障信息。 @@ -346,6 +347,35 @@ function parseToolCallArguments(toolCall: AiToolCall): }; } +function parseRequestedTimeoutMs(toolArgs: Record): { + requestedTimeoutMs?: number; + sanitizedToolArgs: Record; +} { + const meta = toolArgs[TOOL_TIMEOUT_META_KEY]; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return { + requestedTimeoutMs: undefined, + sanitizedToolArgs: toolArgs, + }; + } + + const sanitizedToolArgs = { ...toolArgs }; + delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY]; + + const timeoutMs = (meta as Record).timeoutMs; + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs)) { + return { + requestedTimeoutMs: undefined, + sanitizedToolArgs, + }; + } + + return { + requestedTimeoutMs: timeoutMs, + sanitizedToolArgs, + }; +} + /** * 负责单次模型执行的底层编排: * - 模型解析 @@ -453,6 +483,7 @@ export class AiRequestExecutor { toolCallMessageId: number | null; sessionId: number | null; onChunk?: RequestExecutionCallbacks['onChunk']; + requestedTimeoutMs?: number; }): Promise<{ toolCall: AiToolCall; result: string; @@ -521,6 +552,7 @@ export class AiRequestExecutor { signal: options.signal, iteration: options.iteration, toolCallId: options.toolCall.id, + requestedTimeoutMs: options.requestedTimeoutMs, resolved: { serverId: mapping.serverId, originalName: mapping.originalName, @@ -736,10 +768,13 @@ export class AiRequestExecutor { }; } - const { toolArgs } = parsedToolArguments; + const { requestedTimeoutMs, sanitizedToolArgs } = parseRequestedTimeoutMs( + parsedToolArguments.toolArgs + ); + const builtInResult = await builtInToolService.executeTool({ toolCall: options.toolCall, - toolArgs, + toolArgs: sanitizedToolArgs, iteration: runtime.iteration, currentModel: runtime.activeModel, hasExecutedBuiltInTool: (toolId) => runtime.executedBuiltInTools.has(toolId), @@ -749,6 +784,7 @@ export class AiRequestExecutor { requestToolApproval: options.requestToolApproval, requestUserQuestions: options.requestUserQuestions, emitToolEvent: (toolEvent) => this.emitToolEvent(options.onChunk, toolEvent), + requestedTimeoutMs, }); if (builtInResult) { @@ -757,12 +793,13 @@ export class AiRequestExecutor { return this.executeMcpToolCall({ toolCall: options.toolCall, - toolArgs, + toolArgs: sanitizedToolArgs, iteration: runtime.iteration, signal: options.signal, toolCallMessageId: options.toolCallMessageId, sessionId: options.persister.getSessionId(), onChunk: options.onChunk, + requestedTimeoutMs, }); } diff --git a/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts b/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts index 67d2b0df..b107e240 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3. +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3. /** * MCP Manager - 管理 MCP 服务器连接和工具调用 @@ -438,6 +438,7 @@ export class McpManager { iteration?: number; toolCallId?: string; resolved?: { serverId: number; originalName: string; toolTimeout: number }; + requestedTimeoutMs?: number; } ): Promise<{ result: string; @@ -455,11 +456,17 @@ export class McpManager { throw new Error(t('agent.mcp.toolNotFound', { toolName })); } + // Apply requested timeout bounded between 1s and 10 mins, default to tool config + let effectiveTimeout = resolved.toolTimeout; + if (options?.requestedTimeoutMs && options.requestedTimeoutMs > 0) { + effectiveTimeout = Math.min(Math.max(options.requestedTimeoutMs, 1000), 600000); + } + // 将工具调用与超时和中止信号进行竞争 const callPromise = this.callTool(resolved.serverId, resolved.originalName, args); const response = await raceWithTimeoutAndSignal( callPromise, - resolved.toolTimeout, + effectiveTimeout, options?.signal ); diff --git a/apps/desktop/src/services/BuiltInToolService/service.ts b/apps/desktop/src/services/BuiltInToolService/service.ts index 0d0a0995..822e790e 100644 --- a/apps/desktop/src/services/BuiltInToolService/service.ts +++ b/apps/desktop/src/services/BuiltInToolService/service.ts @@ -49,6 +49,7 @@ interface BuiltInToolExecutionOptions { questions: AskUserQuestion[] ) => Promise; emitToolEvent: (event: ToolEvent) => void; + requestedTimeoutMs?: number; } interface BuiltInToolExecutionResponse { @@ -257,6 +258,7 @@ class BuiltInToolService { emitToolEvent: options.emitToolEvent, hasExecutedBuiltInTool: options.hasExecutedBuiltInTool, requestUserQuestions: options.requestUserQuestions, + requestedTimeoutMs: options.requestedTimeoutMs, }; const callStartTime = Date.now(); diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts index 8eae42d3..0a62daef 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts @@ -153,12 +153,19 @@ export async function executeBashTool( context: BaseBuiltInToolExecutionContext ): Promise { const commandContext = await resolveCommandContext(args, config); + + // Apply requested timeout bounded between 1s and 10 mins, default to tool config + let effectiveTimeout = config.timeoutMs; + if (context.requestedTimeoutMs && context.requestedTimeoutMs > 0) { + effectiveTimeout = Math.min(Math.max(context.requestedTimeoutMs, 1000), 600000); + } + const response = await executeCancelableBash( { executionId: context.callId, command: commandContext.command, workingDirectory: commandContext.workingDirectory, - timeoutMs: config.timeoutMs, + timeoutMs: effectiveTimeout, compactOutput: config.compactOutput, rawOutput: commandContext.rawOutput, }, diff --git a/apps/desktop/src/services/BuiltInToolService/types.ts b/apps/desktop/src/services/BuiltInToolService/types.ts index 68f1572e..c38fb554 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -42,6 +42,7 @@ export interface BaseBuiltInToolExecutionContext { callId: string, questions: AskUserQuestion[] ) => Promise; + requestedTimeoutMs?: number; } /** From c12b88f191a65d302d33946db30fd8dfe7dccca5 Mon Sep 17 00:00:00 2001 From: hongyu <2379713353@qq.com> Date: Wed, 3 Jun 2026 20:06:07 +0800 Subject: [PATCH 2/4] fix(desktop): add min/max to timeout schema; enforce integer; use shared clamp helper --- .../src/services/AgentService/catalog/tools.ts | 4 +++- .../src/services/AgentService/execution/executor.ts | 6 +++++- .../AgentService/infrastructure/mcp/McpManager.ts | 11 +++++++---- .../services/BuiltInToolService/tools/bash/index.ts | 11 +++++++---- apps/desktop/src/utils/timeouts.ts | 13 +++++++++++++ 5 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/utils/timeouts.ts diff --git a/apps/desktop/src/services/AgentService/catalog/tools.ts b/apps/desktop/src/services/AgentService/catalog/tools.ts index 4289458c..c81fb981 100644 --- a/apps/desktop/src/services/AgentService/catalog/tools.ts +++ b/apps/desktop/src/services/AgentService/catalog/tools.ts @@ -18,7 +18,9 @@ const TOOL_TIMEOUT_META_PROPERTY = { timeoutMs: { type: 'integer', description: - 'Optional timeout in milliseconds. Use only when a task is expected to take longer or shorter than the default. Maximum: 600000 (10 minutes).', + 'Optional timeout in milliseconds. Use only when a task is expected to take longer or shorter than the default. Minimum: 1000 (1 second). Maximum: 600000 (10 minutes).', + minimum: 1000, + maximum: 600000, }, }, } as const; diff --git a/apps/desktop/src/services/AgentService/execution/executor.ts b/apps/desktop/src/services/AgentService/execution/executor.ts index 203d82b3..368f4537 100644 --- a/apps/desktop/src/services/AgentService/execution/executor.ts +++ b/apps/desktop/src/services/AgentService/execution/executor.ts @@ -363,7 +363,11 @@ function parseRequestedTimeoutMs(toolArgs: Record): { delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY]; const timeoutMs = (meta as Record).timeoutMs; - if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs)) { + if ( + typeof timeoutMs !== 'number' || + !Number.isFinite(timeoutMs) || + !Number.isInteger(timeoutMs) + ) { return { requestedTimeoutMs: undefined, sanitizedToolArgs, diff --git a/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts b/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts index b107e240..38a73898 100644 --- a/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts +++ b/apps/desktop/src/services/AgentService/infrastructure/mcp/McpManager.ts @@ -28,6 +28,7 @@ import { eq } from 'drizzle-orm'; import { t } from '@/i18n'; import { parseMcpToolSchemaJson } from '@/utils/mcpSchemas'; +import { clampTimeoutMs } from '@/utils/timeouts'; import type { AiToolDefinition } from '../../contracts/tooling'; import { @@ -457,10 +458,12 @@ export class McpManager { } // Apply requested timeout bounded between 1s and 10 mins, default to tool config - let effectiveTimeout = resolved.toolTimeout; - if (options?.requestedTimeoutMs && options.requestedTimeoutMs > 0) { - effectiveTimeout = Math.min(Math.max(options.requestedTimeoutMs, 1000), 600000); - } + const effectiveTimeout = clampTimeoutMs( + options?.requestedTimeoutMs, + resolved.toolTimeout, + 1000, + 600000 + ); // 将工具调用与超时和中止信号进行竞争 const callPromise = this.callTool(resolved.serverId, resolved.originalName, args); diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts index 0a62daef..df3dbd6e 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts @@ -6,6 +6,7 @@ import { t, tt } from '@/i18n'; import { AiError, AiErrorCode } from '@/services/AgentService/contracts/errors'; import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; import { normalizeOptionalString, truncateText } from '@/utils/text'; +import { clampTimeoutMs } from '@/utils/timeouts'; import { type BaseBuiltInToolExecutionContext, @@ -155,10 +156,12 @@ export async function executeBashTool( const commandContext = await resolveCommandContext(args, config); // Apply requested timeout bounded between 1s and 10 mins, default to tool config - let effectiveTimeout = config.timeoutMs; - if (context.requestedTimeoutMs && context.requestedTimeoutMs > 0) { - effectiveTimeout = Math.min(Math.max(context.requestedTimeoutMs, 1000), 600000); - } + const effectiveTimeout = clampTimeoutMs( + context.requestedTimeoutMs, + config.timeoutMs, + 1000, + 600000 + ); const response = await executeCancelableBash( { diff --git a/apps/desktop/src/utils/timeouts.ts b/apps/desktop/src/utils/timeouts.ts new file mode 100644 index 00000000..d5e05f95 --- /dev/null +++ b/apps/desktop/src/utils/timeouts.ts @@ -0,0 +1,13 @@ +export function clampTimeoutMs( + requested: number | undefined | null, + fallback: number, + min = 1000, + max = 600000 +): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return fallback; + } + const integer = Math.floor(requested); + const bounded = Math.min(Math.max(integer, min), max); + return bounded; +} From 80d10d35d161ef95130cb0a12cb05d725b93fa2f Mon Sep 17 00:00:00 2001 From: hongyu <2379713353@qq.com> Date: Wed, 3 Jun 2026 20:43:21 +0800 Subject: [PATCH 3/4] fix(desktop): always strip invalid timeout metadata --- .../src/services/AgentService/execution/executor.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/services/AgentService/execution/executor.ts b/apps/desktop/src/services/AgentService/execution/executor.ts index 368f4537..32ba267e 100644 --- a/apps/desktop/src/services/AgentService/execution/executor.ts +++ b/apps/desktop/src/services/AgentService/execution/executor.ts @@ -352,16 +352,19 @@ function parseRequestedTimeoutMs(toolArgs: Record): { sanitizedToolArgs: Record; } { const meta = toolArgs[TOOL_TIMEOUT_META_KEY]; + const sanitizedToolArgs = + TOOL_TIMEOUT_META_KEY in toolArgs ? { ...toolArgs } : toolArgs; + if (TOOL_TIMEOUT_META_KEY in sanitizedToolArgs) { + delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY]; + } + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { return { requestedTimeoutMs: undefined, - sanitizedToolArgs: toolArgs, + sanitizedToolArgs, }; } - const sanitizedToolArgs = { ...toolArgs }; - delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY]; - const timeoutMs = (meta as Record).timeoutMs; if ( typeof timeoutMs !== 'number' || From 1ef69a53d4f2f0d0e263fd1a0b7072a219a89bc7 Mon Sep 17 00:00:00 2001 From: hongyu <2379713353@qq.com> Date: Wed, 3 Jun 2026 20:59:37 +0800 Subject: [PATCH 4/4] style(desktop): keep timeout sanitization on one line --- apps/desktop/src/services/AgentService/execution/executor.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/services/AgentService/execution/executor.ts b/apps/desktop/src/services/AgentService/execution/executor.ts index 32ba267e..f34fe5a0 100644 --- a/apps/desktop/src/services/AgentService/execution/executor.ts +++ b/apps/desktop/src/services/AgentService/execution/executor.ts @@ -352,8 +352,7 @@ function parseRequestedTimeoutMs(toolArgs: Record): { sanitizedToolArgs: Record; } { const meta = toolArgs[TOOL_TIMEOUT_META_KEY]; - const sanitizedToolArgs = - TOOL_TIMEOUT_META_KEY in toolArgs ? { ...toolArgs } : toolArgs; + const sanitizedToolArgs = TOOL_TIMEOUT_META_KEY in toolArgs ? { ...toolArgs } : toolArgs; if (TOOL_TIMEOUT_META_KEY in sanitizedToolArgs) { delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY]; }