Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions apps/desktop/src/services/AgentService/catalog/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
};
}

/**
* 解析当前模型可用的工具定义列表。
*/
Expand All @@ -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);
}
49 changes: 46 additions & 3 deletions apps/desktop/src/services/AgentService/execution/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 和协议层排障信息。
Expand Down Expand Up @@ -346,6 +347,41 @@ function parseToolCallArguments(toolCall: AiToolCall):
};
}

function parseRequestedTimeoutMs(toolArgs: Record<string, unknown>): {
requestedTimeoutMs?: number;
sanitizedToolArgs: Record<string, unknown>;
} {
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<string, unknown>).timeoutMs;
if (
typeof timeoutMs !== 'number' ||
!Number.isFinite(timeoutMs) ||
!Number.isInteger(timeoutMs)
) {
return {
requestedTimeoutMs: undefined,
sanitizedToolArgs,
};
}

return {
requestedTimeoutMs: timeoutMs,
sanitizedToolArgs,
};
}
Comment on lines +350 to +383

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider simplifying the sanitization copy logic.

The conditional shallow copy at line 355 works correctly but is more complex than necessary. The code only creates a copy when _meta exists, then deletes it. A simpler and safer approach would be to always copy and always delete:

const sanitizedToolArgs = { ...toolArgs };
delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY];

This is clearer (one code path instead of two) and safer (always returns a new object, preventing potential reference-based mutations).

♻️ Proposed refactor
 function parseRequestedTimeoutMs(toolArgs: Record<string, unknown>): {
     requestedTimeoutMs?: number;
     sanitizedToolArgs: Record<string, unknown>;
 } {
     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];
-    }
+    const sanitizedToolArgs = { ...toolArgs };
+    delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY];

     if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
         return {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/services/AgentService/execution/executor.ts` around lines
350 - 383, The sanitization logic in parseRequestedTimeoutMs uses a conditional
shallow copy which is more complex and can return the original object; always
create a shallow copy and remove the meta key to avoid reference mutations.
Replace the conditional copy with a single copy of toolArgs into
sanitizedToolArgs and always delete sanitizedToolArgs[TOOL_TIMEOUT_META_KEY];
keep the rest of the validation logic for timeoutMs unchanged so
parseRequestedTimeoutMs and usage of TOOL_TIMEOUT_META_KEY remain consistent.


/**
* 负责单次模型执行的底层编排:
* - 模型解析
Expand Down Expand Up @@ -453,6 +489,7 @@ export class AiRequestExecutor {
toolCallMessageId: number | null;
sessionId: number | null;
onChunk?: RequestExecutionCallbacks['onChunk'];
requestedTimeoutMs?: number;
}): Promise<{
toolCall: AiToolCall;
result: string;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -749,6 +790,7 @@ export class AiRequestExecutor {
requestToolApproval: options.requestToolApproval,
requestUserQuestions: options.requestUserQuestions,
emitToolEvent: (toolEvent) => this.emitToolEvent(options.onChunk, toolEvent),
requestedTimeoutMs,
});

if (builtInResult) {
Expand All @@ -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,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 服务器连接和工具调用
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -438,6 +439,7 @@ export class McpManager {
iteration?: number;
toolCallId?: string;
resolved?: { serverId: number; originalName: string; toolTimeout: number };
requestedTimeoutMs?: number;
}
): Promise<{
result: string;
Expand All @@ -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
);

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/services/BuiltInToolService/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface BuiltInToolExecutionOptions {
questions: AskUserQuestion[]
) => Promise<AskUserAnswer[] | null>;
emitToolEvent: (event: ToolEvent) => void;
requestedTimeoutMs?: number;
}

interface BuiltInToolExecutionResponse {
Expand Down Expand Up @@ -257,6 +258,7 @@ class BuiltInToolService {
emitToolEvent: options.emitToolEvent,
hasExecutedBuiltInTool: options.hasExecutedBuiltInTool,
requestUserQuestions: options.requestUserQuestions,
requestedTimeoutMs: options.requestedTimeoutMs,
};

const callStartTime = Date.now();
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -153,12 +154,21 @@ export async function executeBashTool(
context: BaseBuiltInToolExecutionContext
): Promise<BuiltInToolExecutionResult> {
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,
},
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/services/BuiltInToolService/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface BaseBuiltInToolExecutionContext {
callId: string,
questions: AskUserQuestion[]
) => Promise<AskUserAnswer[] | null>;
requestedTimeoutMs?: number;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/utils/timeouts.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading