From 336c618befbb9a54c2816d3edfd15a8540dffec0 Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Sat, 8 Aug 2026 11:03:36 +0700 Subject: [PATCH 1/7] feat(agent): make MCP tool call timeout configurable - add Tool timeout field to agent settings (Advanced Settings, shown when MCP tools are connected, default 10s) - pass the value through to the actual MCP tool call instead of the hardcoded 10s - keep 10s default for existing agents without the field - add unit test verifying default_timeout is applied --- agent/component/agent_with_tools.py | 5 +- agent/tools/base.py | 8 ++- .../tools/test_llm_tool_plugin_session.py | 64 +++++++++++++++++++ web/src/locales/en.ts | 3 + web/src/locales/ru.ts | 3 + web/src/locales/zh.ts | 3 + web/src/pages/agent/constant/index.tsx | 1 + web/src/pages/agent/form/agent-form/index.tsx | 28 ++++++++ .../pages/agent/form/agent-form/use-values.ts | 1 + 9 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 test/unit_test/agent/tools/test_llm_tool_plugin_session.py diff --git a/agent/component/agent_with_tools.py b/agent/component/agent_with_tools.py index dc4a0cf0deb..107c7af489b 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,9 @@ 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=float(self._param.tool_timeout or 10) + ) 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..c7adf4d8bad 100644 --- a/agent/tools/base.py +++ b/agent/tools/base.py @@ -48,14 +48,16 @@ class ToolMeta(TypedDict): class LLMToolPluginCallSession(ToolCallSession): - def __init__(self, tools_map: dict[str, object], callback: partial): + def __init__(self, tools_map: dict[str, object], callback: partial, default_timeout: float = 10): self.tools_map = tools_map self.callback = callback + self.default_timeout = 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: 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: + 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]}") if not isinstance(arguments, Mapping): diff --git a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py new file mode 100644 index 00000000000..89f0b9aa038 --- /dev/null +++ b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py @@ -0,0 +1,64 @@ +# +# 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 +import time +from functools import partial + +from agent.tools.base import LLMToolPluginCallSession +from common.mcp_tool_call_conn import MCPToolBinding + + +class _BlockingSession: + """Fake MCP session that waits until the caller-provided timeout elapses, mimicking MCPToolCallSession.""" + + def tool_call(self, name: str, arguments: dict, timeout: float = 10) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(0.005) + return "done" + + +def _noop_callback(*args, **kwargs): + pass + + +def _make_session(default_timeout): + return LLMToolPluginCallSession( + {"transcribe_0": MCPToolBinding(_BlockingSession(), "transcribe")}, + partial(_noop_callback), + default_timeout=default_timeout, + ) + + +def test_default_timeout_is_applied_to_mcp_tool_call(): + session = _make_session(default_timeout=0.1) + start = time.monotonic() + result = asyncio.run(session.tool_call_async("transcribe_0", {})) + elapsed = time.monotonic() - start + assert result == "done" + # Without the session default the 10s tool_call default would run far longer. + assert elapsed < 1.0 + + +def test_explicit_request_timeout_overrides_default(): + session = _make_session(default_timeout=0.5) + start = time.monotonic() + result = asyncio.run(session.tool_call_async("transcribe_0", {}, request_timeout=0.05)) + elapsed = time.monotonic() - start + assert result == "done" + assert elapsed < 0.3 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..70a4e0a13b3 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,29 @@ function AgentForm({ node }: INextOperatorForm) { )} /> + {mcpIds.length > 0 && ( + ( + + + {t('flow.toolTimeout')} + + +
+ {' '} + {t('flow.seconds')} +
+
+
+ )} + /> + )} {hasSubAgentOrTool(edges, node?.id) && ( Date: Sat, 8 Aug 2026 12:46:18 +0700 Subject: [PATCH 2/7] feat(agent): validate tool timeout and assert timeout propagation - fall back to the 10s default when tool_timeout is missing or below 1 - stop converting falsy tool_timeout values to 10 in agent_with_tools - log the resolved request_timeout on tool invocation - rewrite unit tests to record and assert the exact timeout received by the MCP session instead of relying on elapsed-time thresholds --- agent/component/agent_with_tools.py | 4 +- agent/tools/base.py | 6 +-- .../tools/test_llm_tool_plugin_session.py | 49 ++++++++++++------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/agent/component/agent_with_tools.py b/agent/component/agent_with_tools.py index 107c7af489b..913de409521 100644 --- a/agent/component/agent_with_tools.py +++ b/agent/component/agent_with_tools.py @@ -112,9 +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, default_timeout=float(self._param.tool_timeout or 10) - ) + 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 c7adf4d8bad..10f72f65bb6 100644 --- a/agent/tools/base.py +++ b/agent/tools/base.py @@ -48,10 +48,10 @@ class ToolMeta(TypedDict): class LLMToolPluginCallSession(ToolCallSession): - def __init__(self, tools_map: dict[str, object], callback: partial, default_timeout: float = 10): + def __init__(self, tools_map: dict[str, object], callback: partial, default_timeout: float | None = None): self.tools_map = tools_map self.callback = callback - self.default_timeout = default_timeout + 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 | None = None) -> Any: return asyncio.run(self.tool_call_async(name, arguments, request_timeout=timeout)) @@ -59,7 +59,7 @@ def tool_call(self, name: str, arguments: dict[str, Any], timeout: float | int | async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int | None = None) -> Any: 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() diff --git a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py index 89f0b9aa038..19b7f0556f9 100644 --- a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py +++ b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py @@ -16,20 +16,20 @@ """Verify that LLMToolPluginCallSession applies its configurable default timeout to MCP tool calls.""" import asyncio -import time from functools import partial from agent.tools.base import LLMToolPluginCallSession from common.mcp_tool_call_conn import MCPToolBinding -class _BlockingSession: - """Fake MCP session that waits until the caller-provided timeout elapses, mimicking MCPToolCallSession.""" +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: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - time.sleep(0.005) + self.timeouts.append(timeout) return "done" @@ -37,28 +37,39 @@ def _noop_callback(*args, **kwargs): pass -def _make_session(default_timeout): - return LLMToolPluginCallSession( - {"transcribe_0": MCPToolBinding(_BlockingSession(), "transcribe")}, +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 = _make_session(default_timeout=0.1) - start = time.monotonic() + session, recording = _make_session(default_timeout=3) result = asyncio.run(session.tool_call_async("transcribe_0", {})) - elapsed = time.monotonic() - start assert result == "done" - # Without the session default the 10s tool_call default would run far longer. - assert elapsed < 1.0 + assert recording.timeouts == [3] def test_explicit_request_timeout_overrides_default(): - session = _make_session(default_timeout=0.5) - start = time.monotonic() - result = asyncio.run(session.tool_call_async("transcribe_0", {}, request_timeout=0.05)) - elapsed = time.monotonic() - start + 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(): + session, recording = _make_session() + 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 elapsed < 0.3 + assert recording.timeouts == [4] From 0ac7aedecebacc83cf250377f18f41e722cc133c Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Sat, 8 Aug 2026 12:53:22 +0700 Subject: [PATCH 3/7] test(agent): cover timeout fallback for 0 and negative values --- .../agent/tools/test_llm_tool_plugin_session.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py index 19b7f0556f9..2232079a0b0 100644 --- a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py +++ b/test/unit_test/agent/tools/test_llm_tool_plugin_session.py @@ -62,10 +62,11 @@ def test_explicit_request_timeout_overrides_default(): def test_constructor_default_timeout_is_ten_seconds(): - session, recording = _make_session() - result = asyncio.run(session.tool_call_async("transcribe_0", {})) - assert result == "done" - assert recording.timeouts == [10] + for configured in (None, 0, -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(): From 244e5db91d5e6ab4eb1ab8ce5233132ee7dc66d0 Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Mon, 10 Aug 2026 16:38:32 +0700 Subject: [PATCH 4/7] test(agent): move timeout test out of agent/tools to avoid pytest namespace conflict The test lived in test/unit_test/agent/tools/, where pytest's prepend import mode resolves 'agent' to the test folder as a namespace package (no __init__.py), breaking 'from agent.tools.base import ...' at collection ('cannot import name ... (unknown location)'). Move it to test/unit_test/agent/ so the root 'agent' package is imported correctly. --- test/unit_test/agent/{tools => }/test_llm_tool_plugin_session.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/unit_test/agent/{tools => }/test_llm_tool_plugin_session.py (100%) diff --git a/test/unit_test/agent/tools/test_llm_tool_plugin_session.py b/test/unit_test/agent/test_llm_tool_plugin_session.py similarity index 100% rename from test/unit_test/agent/tools/test_llm_tool_plugin_session.py rename to test/unit_test/agent/test_llm_tool_plugin_session.py From 9cb7924f92c52e3df7be09cd3099e171768e4881 Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Mon, 10 Aug 2026 16:51:55 +0700 Subject: [PATCH 5/7] test(agent): cover positive sub-second timeout fallback --- test/unit_test/agent/test_llm_tool_plugin_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit_test/agent/test_llm_tool_plugin_session.py b/test/unit_test/agent/test_llm_tool_plugin_session.py index 2232079a0b0..502cc886af4 100644 --- a/test/unit_test/agent/test_llm_tool_plugin_session.py +++ b/test/unit_test/agent/test_llm_tool_plugin_session.py @@ -62,7 +62,7 @@ def test_explicit_request_timeout_overrides_default(): def test_constructor_default_timeout_is_ten_seconds(): - for configured in (None, 0, -1): + 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" From d9bf6743aff24debd522ddf8f177474958457a8f Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Thu, 27 Aug 2026 10:31:44 +0700 Subject: [PATCH 6/7] fix(agent): add docstrings to LLMToolPluginCallSession for coverage --- agent/tools/base.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/agent/tools/base.py b/agent/tools/base.py index 10f72f65bb6..63cc35b4e05 100644 --- a/agent/tools/base.py +++ b/agent/tools/base.py @@ -48,15 +48,27 @@ class ToolMeta(TypedDict): class LLMToolPluginCallSession(ToolCallSession): + """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 | 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 | 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]} request_timeout={request_timeout}") @@ -94,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] From 94a87848b532a862ba01d1b7f830f440d541bbe7 Mon Sep 17 00:00:00 2001 From: Mikhail Medvedev Date: Thu, 27 Aug 2026 10:37:35 +0700 Subject: [PATCH 7/7] fix(web): use RAGFlowFormItem for tool_timeout field --- web/src/pages/agent/form/agent-form/index.tsx | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/web/src/pages/agent/form/agent-form/index.tsx b/web/src/pages/agent/form/agent-form/index.tsx index 70a4e0a13b3..d22f2e64087 100644 --- a/web/src/pages/agent/form/agent-form/index.tsx +++ b/web/src/pages/agent/form/agent-form/index.tsx @@ -254,27 +254,22 @@ function AgentForm({ node }: INextOperatorForm) { )} /> {mcpIds.length > 0 && ( - ( - - - {t('flow.toolTimeout')} - - -
- {' '} - {t('flow.seconds')} -
-
-
+ + {(field) => ( +
+ {' '} + {t('flow.seconds')} +
)} - /> +
)} {hasSubAgentOrTool(edges, node?.id) && (