diff --git a/apps/desktop/src/services/AgentService/catalog/tools.ts b/apps/desktop/src/services/AgentService/catalog/tools.ts index 583f980d..c81fb981 100644 --- a/apps/desktop/src/services/AgentService/catalog/tools.ts +++ b/apps/desktop/src/services/AgentService/catalog/tools.ts @@ -11,6 +11,33 @@ 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. Minimum: 1000 (1 second). Maximum: 600000 (10 minutes).', + minimum: 1000, + maximum: 600000, + }, + }, +} 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 +54,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..f34fe5a0 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,41 @@ function parseToolCallArguments(toolCall: AiToolCall): }; } +function parseRequestedTimeoutMs(toolArgs: Record): { + requestedTimeoutMs?: number; + 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, + }; + } + + const timeoutMs = (meta as Record).timeoutMs; + if ( + typeof timeoutMs !== 'number' || + !Number.isFinite(timeoutMs) || + !Number.isInteger(timeoutMs) + ) { + return { + requestedTimeoutMs: undefined, + sanitizedToolArgs, + }; + } + + return { + requestedTimeoutMs: timeoutMs, + sanitizedToolArgs, + }; +} + /** * 负责单次模型执行的底层编排: * - 模型解析 @@ -453,6 +489,7 @@ export class AiRequestExecutor { toolCallMessageId: number | null; sessionId: number | null; onChunk?: RequestExecutionCallbacks['onChunk']; + requestedTimeoutMs?: number; }): Promise<{ toolCall: AiToolCall; result: string; @@ -521,6 +558,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 +774,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 +790,7 @@ export class AiRequestExecutor { requestToolApproval: options.requestToolApproval, requestUserQuestions: options.requestUserQuestions, emitToolEvent: (toolEvent) => this.emitToolEvent(options.onChunk, toolEvent), + requestedTimeoutMs, }); if (builtInResult) { @@ -757,12 +799,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..38a73898 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 服务器连接和工具调用 @@ -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 { @@ -438,6 +439,7 @@ export class McpManager { iteration?: number; toolCallId?: string; resolved?: { serverId: number; originalName: string; toolTimeout: number }; + requestedTimeoutMs?: number; } ): Promise<{ result: string; @@ -455,11 +457,19 @@ export class McpManager { throw new Error(t('agent.mcp.toolNotFound', { toolName })); } + // Apply requested timeout bounded between 1s and 10 mins, default to tool config + const effectiveTimeout = clampTimeoutMs( + options?.requestedTimeoutMs, + resolved.toolTimeout, + 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..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, @@ -153,12 +154,21 @@ 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 + const effectiveTimeout = clampTimeoutMs( + context.requestedTimeoutMs, + config.timeoutMs, + 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; } /** 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; +}