diff --git a/agent/component/agent_with_tools.py b/agent/component/agent_with_tools.py index dc4a0cf0deb..913de409521 100644 --- a/agent/component/agent_with_tools.py +++ b/agent/component/agent_with_tools.py @@ -69,6 +69,7 @@ def __init__(self): self.max_rounds = 5 self.description = "" self.custom_header = {} + self.tool_timeout = 10 class Agent(LLM, ToolBase): @@ -111,7 +112,7 @@ def __init__(self, canvas, id, param: LLMParam): self.tool_meta.append(mcp_tool_metadata_to_openai_tool(meta, function_name=indexed_name)) self.tools[indexed_name] = MCPToolBinding(tool_call_session, tnm) self.callback = partial(self._canvas.tool_use_callback, id) - self.toolcall_session = LLMToolPluginCallSession(self.tools, self.callback) + self.toolcall_session = LLMToolPluginCallSession(self.tools, self.callback, default_timeout=self._param.tool_timeout) if self.tool_meta: self.chat_mdl.bind_tools(self.toolcall_session, self.tool_meta) diff --git a/agent/tools/base.py b/agent/tools/base.py index 1cb1fa23fc5..63cc35b4e05 100644 --- a/agent/tools/base.py +++ b/agent/tools/base.py @@ -48,16 +48,30 @@ class ToolMeta(TypedDict): class LLMToolPluginCallSession(ToolCallSession): - def __init__(self, tools_map: dict[str, object], callback: partial): + """Session that dispatches LLM tool calls with a configurable default timeout.""" + + def __init__(self, tools_map: dict[str, object], callback: partial, default_timeout: float | None = None): + """Initialize the session with a normalized default timeout. + + Args: + tools_map: Mapping from indexed tool name to tool object. + callback: Callback invoked after each tool call. + default_timeout: Timeout in seconds for a single tool call. ``None`` or + values below ``1`` fall back to ``10``. + """ self.tools_map = tools_map self.callback = callback + self.default_timeout = 10 if default_timeout is None or default_timeout < 1 else float(default_timeout) - def tool_call(self, name: str, arguments: dict[str, Any], timeout: float | int = 10) -> Any: + def tool_call(self, name: str, arguments: dict[str, Any], timeout: float | int | None = None) -> Any: + """Synchronous wrapper for :meth:`tool_call_async`.""" return asyncio.run(self.tool_call_async(name, arguments, request_timeout=timeout)) - async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int = 10) -> Any: + async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int | None = None) -> Any: + """Invoke a tool asynchronously, applying the default timeout when needed.""" + request_timeout = self.default_timeout if request_timeout is None else request_timeout assert name in self.tools_map, f"LLM tool {name} does not exist" - logging.info(f"[ToolCall] invoke name={name} arguments={str(arguments)[:200]}") + logging.info(f"[ToolCall] invoke name={name} arguments={str(arguments)[:200]} request_timeout={request_timeout}") if not isinstance(arguments, Mapping): raise TypeError(f"Tool arguments for {name} must be an object, got {type(arguments).__name__}") st = timer() @@ -92,6 +106,7 @@ async def tool_call_async(self, name: str, arguments: dict[str, Any], request_ti return resp def get_tool_obj(self, name): + """Return the raw tool object for a given indexed name.""" return self.tools_map[name] diff --git a/test/unit_test/agent/test_llm_tool_plugin_session.py b/test/unit_test/agent/test_llm_tool_plugin_session.py new file mode 100644 index 00000000000..502cc886af4 --- /dev/null +++ b/test/unit_test/agent/test_llm_tool_plugin_session.py @@ -0,0 +1,76 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Verify that LLMToolPluginCallSession applies its configurable default timeout to MCP tool calls.""" + +import asyncio +from functools import partial + +from agent.tools.base import LLMToolPluginCallSession +from common.mcp_tool_call_conn import MCPToolBinding + + +class _RecordingSession: + """Fake MCP session that records the timeout supplied by each call.""" + + def __init__(self): + self.timeouts = [] + + def tool_call(self, name: str, arguments: dict, timeout: float = 10) -> str: + self.timeouts.append(timeout) + return "done" + + +def _noop_callback(*args, **kwargs): + pass + + +def _make_session(default_timeout=None): + recording = _RecordingSession() + session = LLMToolPluginCallSession( + {"transcribe_0": MCPToolBinding(recording, "transcribe")}, + partial(_noop_callback), + default_timeout=default_timeout, + ) + return session, recording + + +def test_default_timeout_is_applied_to_mcp_tool_call(): + session, recording = _make_session(default_timeout=3) + result = asyncio.run(session.tool_call_async("transcribe_0", {})) + assert result == "done" + assert recording.timeouts == [3] + + +def test_explicit_request_timeout_overrides_default(): + session, recording = _make_session(default_timeout=5) + result = asyncio.run(session.tool_call_async("transcribe_0", {}, request_timeout=2)) + assert result == "done" + assert recording.timeouts == [2] + + +def test_constructor_default_timeout_is_ten_seconds(): + for configured in (None, 0, 0.5, -1): + session, recording = _make_session(default_timeout=configured) + result = asyncio.run(session.tool_call_async("transcribe_0", {})) + assert result == "done" + assert recording.timeouts == [10] + + +def test_tool_call_wrapper_uses_default_timeout(): + session, recording = _make_session(default_timeout=4) + result = session.tool_call("transcribe_0", {}) + assert result == "done" + assert recording.timeouts == [4] diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index e0eefa15e5b..d23b74db5c5 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2602,6 +2602,9 @@ Best for: Documents with flowing, contextually connected content — such as boo maxRounds: 'Max reflection rounds', delayAfterError: 'Delay after error', maxRetries: 'Max retry rounds', + toolTimeout: 'Tool timeout', + toolTimeoutTip: + 'Timeout in seconds for a single tool call (including MCP tools). Increase it for long-running tools.', maxSteps: 'Max steps', headless: 'Headless', enableDefaultExtensions: 'Enable default extensions', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 00ca6e804b0..e145820c43a 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1599,6 +1599,9 @@ export default { maxRounds: 'Максимальное количество раундов рефлексии', delayAfterError: 'Задержка после ошибки', maxRetries: 'Максимальное количество попыток повтора', + toolTimeout: 'Таймаут вызова инструмента', + toolTimeoutTip: + 'Таймаут в секундах для одного вызова инструмента (включая MCP). Увеличьте для длительно выполняющихся инструментов.', advancedSettings: 'Расширенные настройки', addTools: 'Добавить инструменты', sysPromptDefaultValue: ` diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 7194d2e8066..7355421f84b 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -2247,6 +2247,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 maxRounds: '最大反思轮数', delayAfterError: '错误后延迟', maxRetries: '最大重试轮数', + toolTimeout: '工具调用超时', + toolTimeoutTip: + '单次工具调用(含 MCP 工具)的超时秒数。长时间运行的工具请调大该值。', maxSteps: '最大步数', headless: '无头模式', enableDefaultExtensions: '启用默认扩展', diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 2ab7360d2fe..66c429a8802 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -491,6 +491,7 @@ export const initialAgentValues = { exception_default_value: '', tools: [], mcp: [], + tool_timeout: 10, cite: true, showStructuredOutput: false, outputs: { diff --git a/web/src/pages/agent/form/agent-form/index.tsx b/web/src/pages/agent/form/agent-form/index.tsx index 2c3bf7730c3..d22f2e64087 100644 --- a/web/src/pages/agent/form/agent-form/index.tsx +++ b/web/src/pages/agent/form/agent-form/index.tsx @@ -19,6 +19,7 @@ import { Input, NumberInput } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; +import NumberInputStepper from '@/components/originui/number-input'; import { useFindLlmByUuid } from '@/hooks/use-llm-request'; import { zodResolver } from '@hookform/resolvers/zod'; import { get } from 'lodash'; @@ -49,6 +50,7 @@ import { useHandleShowStructuredOutput, useShowStructuredOutputDialog, } from './use-show-structured-output-dialog'; +import { useGetAgentMCPIds } from './use-get-tools'; import { useValues } from './use-values'; import { useWatchFormChange } from './use-watch-change'; @@ -74,6 +76,7 @@ const FormSchema = z.object({ exception_method: z.string().optional(), exception_goto: z.array(z.string()).optional(), exception_default_value: z.string().optional(), + tool_timeout: z.coerce.number().optional(), ...LargeModelFilterFormSchema, cite: z.boolean().optional(), showStructuredOutput: z.boolean().optional(), @@ -118,6 +121,8 @@ function AgentForm({ node }: INextOperatorForm) { name: 'exception_method', }); + const { mcpIds } = useGetAgentMCPIds(); + const showStructuredOutput = useWatch({ control: form.control, name: 'showStructuredOutput', @@ -248,6 +253,24 @@ function AgentForm({ node }: INextOperatorForm) { )} /> + {mcpIds.length > 0 && ( + + {(field) => ( +
+ {' '} + {t('flow.seconds')} +
+ )} +
+ )} {hasSubAgentOrTool(edges, node?.id) && (