From f25f2ca35cbbf9d1943e93cfbf838f1fee02a48b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 15:51:16 +0800 Subject: [PATCH 1/5] feat: implement llm guidance for repetition tool call fixes: #7387 --- .../agent/runners/tool_loop_agent_runner.py | 56 +++++++- .../provider/ProviderSourcesPanel.vue | 1 - tests/test_tool_loop_agent_runner.py | 121 ++++++++++++++++++ 3 files changed, 173 insertions(+), 5 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3fb487cbe6..a6f89dd792 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -209,6 +209,8 @@ async def reset( self._abort_signal = asyncio.Event() self._pending_follow_ups: list[FollowUpTicket] = [] self._follow_up_seq = 0 + self._last_tool_name: str | None = None + self._same_tool_streak = 0 # These two are used for tool schema mode handling # We now have two modes: @@ -427,6 +429,41 @@ def _merge_follow_up_notice(self, content: str) -> str: return content return f"{content}{notice}" + def _track_tool_call_streak(self, tool_name: str) -> int: + if tool_name == self._last_tool_name: + self._same_tool_streak += 1 + else: + self._last_tool_name = tool_name + self._same_tool_streak = 1 + return self._same_tool_streak + + def _build_same_tool_guidance(self, tool_name: str, streak: int) -> str: + if streak < 3: + return "" + + if streak >= 5: + return ( + "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " + f"`{tool_name}` {streak} times consecutively. Repetition is now very " + "high. Continue only if each call is clearly producing new information. " + "Otherwise, change strategy, adjust arguments, or explain the limitation " + "to the user." + ) + + if streak >= 3: + return ( + "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " + f"`{tool_name}` {streak} times consecutively. Unless this repetition is " + "clearly necessary, stop repeating the same action and either switch " + "tools, refine parameters, or summarize what is still missing." + ) + + return ( + "\n\n[SYSTEM NOTICE] By the way, you have executed the same tool " + f"`{tool_name}` {streak} times consecutively. Double-check whether another " + "tool, different arguments, or a summary would move the task forward better." + ) + @override async def step(self): """Process a single step of the agent. @@ -712,6 +749,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: llm_response.tools_call_args, llm_response.tools_call_ids, ): + tool_call_streak = self._track_tool_call_streak(func_tool_name) yield _HandleFunctionToolsResult.from_message_chain( MessageChain( type="tool_call", @@ -861,7 +899,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: if result_parts: _append_tool_call_result( func_tool_id, - "\n\n".join(result_parts), + "\n\n".join(result_parts) + + self._build_same_tool_guidance( + func_tool_name, tool_call_streak + ), ) elif resp is None: @@ -875,7 +916,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: self.stats.end_time = time.time() _append_tool_call_result( func_tool_id, - "The tool has no return value, or has sent the result directly to the user.", + "The tool has no return value, or has sent the result directly to the user." + + self._build_same_tool_guidance( + func_tool_name, tool_call_streak + ), ) else: # 不应该出现其他类型 @@ -884,7 +928,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: ) _append_tool_call_result( func_tool_id, - "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*", + "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*" + + self._build_same_tool_guidance( + func_tool_name, tool_call_streak + ), ) try: @@ -902,7 +949,8 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: logger.warning(traceback.format_exc()) _append_tool_call_result( func_tool_id, - f"error: {e!s}", + f"error: {e!s}" + + self._build_same_tool_guidance(func_tool_name, tool_call_streak), ) # yield the last tool call result diff --git a/dashboard/src/components/provider/ProviderSourcesPanel.vue b/dashboard/src/components/provider/ProviderSourcesPanel.vue index 3a860eb124..7e4e443d98 100644 --- a/dashboard/src/components/provider/ProviderSourcesPanel.vue +++ b/dashboard/src/components/provider/ProviderSourcesPanel.vue @@ -224,7 +224,6 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source) } .provider-source-list { - max-height: calc(100vh - 335px); overflow-y: auto; padding: 0; background: transparent; diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 13ea6dac7b..936a11842b 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -193,6 +193,32 @@ async def text_chat(self, **kwargs) -> LLMResponse: ) +class SequentialToolProvider(MockProvider): + def __init__(self, tool_sequence: list[str]): + super().__init__() + self.tool_sequence = tool_sequence + + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + func_tool = kwargs.get("func_tool") + if func_tool is None or self.call_count > len(self.tool_sequence): + return LLMResponse( + role="assistant", + completion_text="这是我的最终回答", + usage=TokenUsage(input_other=10, output=5), + ) + + tool_name = self.tool_sequence[self.call_count - 1] + return LLMResponse( + role="assistant", + completion_text="", + tools_call_name=[tool_name], + tools_call_args=[{"query": f"step-{self.call_count}"}], + tools_call_ids=[f"call_{self.call_count}"], + usage=TokenUsage(input_other=10, output=5), + ) + + class MockHandoffProvider(MockToolCallProvider): def __init__(self, handoff_tool_name: str): super().__init__(handoff_tool_name, {"input": "delegate this task"}) @@ -538,6 +564,101 @@ def fake_save_image( ] +@pytest.mark.asyncio +async def test_same_tool_consecutive_results_include_escalating_guidance( + runner, mock_tool_executor, mock_hooks +): + provider = SequentialToolProvider(["test_tool"] * 5) + tool = FunctionTool( + name="test_tool", + description="测试工具", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + request = ProviderRequest( + prompt="请连续执行工具", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(6): + pass + + tool_messages = [ + m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" + ] + assert len(tool_messages) == 5 + + tool_contents = [str(message.content) for message in tool_messages] + assert "same tool" not in tool_contents[0] + assert "By the way" in tool_contents[1] + assert "2 times consecutively" in tool_contents[1] + assert "Important" in tool_contents[2] + assert "3 times consecutively" in tool_contents[2] + assert "Important" in tool_contents[4] + assert "5 times consecutively" in tool_contents[4] + assert "very high" in tool_contents[4] + + +@pytest.mark.asyncio +async def test_same_tool_streak_resets_after_switching_tools( + runner, mock_tool_executor, mock_hooks +): + provider = SequentialToolProvider( + ["test_tool", "other_tool", "test_tool", "test_tool"] + ) + tool_a = FunctionTool( + name="test_tool", + description="测试工具 A", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + tool_b = FunctionTool( + name="other_tool", + description="测试工具 B", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + request = ProviderRequest( + prompt="切换工具后再重复", + func_tool=ToolSet(tools=[tool_a, tool_b]), + contexts=[], + ) + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(5): + pass + + tool_messages = [ + m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" + ] + assert len(tool_messages) == 4 + + tool_contents = [str(message.content) for message in tool_messages] + assert "same tool" not in tool_contents[0] + assert "same tool" not in tool_contents[1] + assert "same tool" not in tool_contents[2] + assert "By the way" in tool_contents[3] + assert "`test_tool` 2 times consecutively" in tool_contents[3] + + @pytest.mark.asyncio async def test_fallback_provider_used_when_primary_raises( runner, provider_request, mock_tool_executor, mock_hooks From 4590479fcccd41fb1e6e270a0512e3da966f0cb6 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 16:03:25 +0800 Subject: [PATCH 2/5] feat: enhance tool execution guidance with user interruption and repetition notices --- .../agent/runners/tool_loop_agent_runner.py | 116 +++++++++++------- tests/test_tool_loop_agent_runner.py | 53 +++++--- 2 files changed, 109 insertions(+), 60 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index a6f89dd792..e4143b25cd 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -95,16 +95,62 @@ class _ToolExecutionInterrupted(Exception): ToolExecutorResultT = T.TypeVar("ToolExecutorResultT") -USER_INTERRUPTION_MESSAGE = ( - "[SYSTEM: User actively interrupted the response generation. " - "Partial output before interruption is preserved.]" -) - class ToolLoopAgentRunner(BaseAgentRunner[TContext]): EMPTY_OUTPUT_RETRY_ATTEMPTS = 3 EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1 EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4 + USER_INTERRUPTION_MESSAGE = ( + "[SYSTEM: User actively interrupted the response generation. " + "Partial output before interruption is preserved.]" + ) + FOLLOW_UP_NOTICE_TEMPLATE = ( + "\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution " + "was in progress. Prioritize these follow-up instructions in your next " + "actions. In your very next action, briefly acknowledge to the user " + "that their follow-up message(s) were received before continuing.\n" + "{follow_up_lines}" + ) + MAX_STEPS_REACHED_PROMPT = ( + "Maximum tool call limit reached. " + "Stop calling tools, and based on the information you have gathered, " + "summarize your task and findings, and reply to the user directly." + ) + SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = ( + "You have decided to call tool(s): {tool_names}. Now call the tool(s) " + "with required arguments using the tool schema, and follow the existing " + "tool-use rules." + ) + SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = ( + "This is the second-stage tool execution step. " + "You must do exactly one of the following: " + "1. Call one of the selected tools using the provided tool schema. " + "2. If calling a tool is no longer possible or appropriate, reply to the user " + "with a brief explanation of why. " + "Do not return an empty response. " + "Do not ignore the selected tools without explanation." + ) + REPEATED_TOOL_NOTICE_L1_THRESHOLD = 2 + REPEATED_TOOL_NOTICE_L2_THRESHOLD = 3 + REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5 + REPEATED_TOOL_NOTICE_L1_TEMPLATE = ( + "\n\n[SYSTEM NOTICE] By the way, you have executed the same tool " + "`{tool_name}` {streak} times consecutively. Double-check whether another " + "tool, different arguments, or a summary would move the task forward better." + ) + REPEATED_TOOL_NOTICE_L2_TEMPLATE = ( + "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " + "`{tool_name}` {streak} times consecutively. Unless this repetition is " + "clearly necessary, stop repeating the same action and either switch " + "tools, refine parameters, or summarize what is still missing." + ) + REPEATED_TOOL_NOTICE_L3_TEMPLATE = ( + "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " + "`{tool_name}` {streak} times consecutively. Repetition is now very " + "high. Continue only if each call is clearly producing new information. " + "Otherwise, change strategy, adjust arguments, or explain the limitation " + "to the user." + ) def _get_persona_custom_error_message(self) -> str | None: """Read persona-level custom error message from event extras when available.""" @@ -415,12 +461,8 @@ def _consume_follow_up_notice(self) -> str: follow_up_lines = "\n".join( f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1) ) - return ( - "\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution " - "was in progress. Prioritize these follow-up instructions in your next " - "actions. In your very next action, briefly acknowledge to the user " - "that their follow-up message(s) were received before continuing.\n" - f"{follow_up_lines}" + return self.FOLLOW_UP_NOTICE_TEMPLATE.format( + follow_up_lines=follow_up_lines, ) def _merge_follow_up_notice(self, content: str) -> str: @@ -438,30 +480,24 @@ def _track_tool_call_streak(self, tool_name: str) -> int: return self._same_tool_streak def _build_same_tool_guidance(self, tool_name: str, streak: int) -> str: - if streak < 3: + if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD: return "" - if streak >= 5: - return ( - "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " - f"`{tool_name}` {streak} times consecutively. Repetition is now very " - "high. Continue only if each call is clearly producing new information. " - "Otherwise, change strategy, adjust arguments, or explain the limitation " - "to the user." + if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD: + return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format( + tool_name=tool_name, + streak=streak, ) - if streak >= 3: - return ( - "\n\n[SYSTEM NOTICE] Important: you have executed the same tool " - f"`{tool_name}` {streak} times consecutively. Unless this repetition is " - "clearly necessary, stop repeating the same action and either switch " - "tools, refine parameters, or summarize what is still missing." + if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD: + return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( + tool_name=tool_name, + streak=streak, ) - return ( - "\n\n[SYSTEM NOTICE] By the way, you have executed the same tool " - f"`{tool_name}` {streak} times consecutively. Double-check whether another " - "tool, different arguments, or a summary would move the task forward better." + return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( + tool_name=tool_name, + streak=streak, ) @override @@ -520,7 +556,7 @@ async def step(self): if self._is_stop_requested(): llm_resp_result = LLMResponse( role="assistant", - completion_text=USER_INTERRUPTION_MESSAGE, + completion_text=self.USER_INTERRUPTION_MESSAGE, reasoning_content=llm_response.reasoning_content, reasoning_signature=llm_response.reasoning_signature, ) @@ -718,7 +754,7 @@ async def step_until_done( self.run_context.messages.append( Message( role="user", - content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。", + content=self.MAX_STEPS_REACHED_PROMPT, ) ) # 再执行最后一步 @@ -990,11 +1026,8 @@ def _build_tool_requery_context( contexts.append(msg.model_dump()) # type: ignore[call-arg] elif isinstance(msg, dict): contexts.append(copy.deepcopy(msg)) - instruction = ( - "You have decided to call tool(s): " - + ", ".join(tool_names) - + ". Now call the tool(s) with required arguments using the tool schema, " - "and follow the existing tool-use rules." + instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format( + tool_names=", ".join(tool_names) ) if extra_instruction: instruction = f"{instruction}\n{extra_instruction}" @@ -1065,14 +1098,7 @@ async def _resolve_tool_exec( ) repair_contexts = self._build_tool_requery_context( tool_names, - extra_instruction=( - "This is the second-stage tool execution step. " - "You must do exactly one of the following: " - "1. Call one of the selected tools using the provided tool schema. " - "2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. " - "Do not return an empty response. " - "Do not ignore the selected tools without explanation." - ), + extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION, ) repair_resp = await self.provider.text_chat( contexts=repair_contexts, @@ -1114,7 +1140,7 @@ async def _finalize_aborted_step( if llm_resp.role != "assistant": llm_resp = LLMResponse( role="assistant", - completion_text=USER_INTERRUPTION_MESSAGE, + completion_text=self.USER_INTERRUPTION_MESSAGE, ) self.final_llm_resp = llm_resp self._aborted = True diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 936a11842b..7558edd2bb 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -11,8 +11,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from astrbot.core.agent.agent import Agent -from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.tool import FunctionTool, ToolSet @@ -599,14 +599,25 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( assert len(tool_messages) == 5 tool_contents = [str(message.content) for message in tool_messages] - assert "same tool" not in tool_contents[0] - assert "By the way" in tool_contents[1] - assert "2 times consecutively" in tool_contents[1] - assert "Important" in tool_contents[2] - assert "3 times consecutively" in tool_contents[2] - assert "Important" in tool_contents[4] - assert "5 times consecutively" in tool_contents[4] - assert "very high" in tool_contents[4] + runner_cls = type(runner) + level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format( + tool_name="test_tool", + streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD, + ) + level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format( + tool_name="test_tool", + streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD, + ) + level_3_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_3_TEMPLATE.format( + tool_name="test_tool", + streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_3_THRESHOLD, + ) + + assert level_1_notice not in tool_contents[0] + assert level_2_notice not in tool_contents[0] + assert level_1_notice in tool_contents[1] + assert level_2_notice in tool_contents[2] + assert level_3_notice in tool_contents[4] @pytest.mark.asyncio @@ -652,11 +663,21 @@ async def test_same_tool_streak_resets_after_switching_tools( assert len(tool_messages) == 4 tool_contents = [str(message.content) for message in tool_messages] - assert "same tool" not in tool_contents[0] - assert "same tool" not in tool_contents[1] - assert "same tool" not in tool_contents[2] - assert "By the way" in tool_contents[3] - assert "`test_tool` 2 times consecutively" in tool_contents[3] + runner_cls = type(runner) + level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format( + tool_name="test_tool", + streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD, + ) + level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format( + tool_name="test_tool", + streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD, + ) + + assert level_1_notice not in tool_contents[0] + assert level_1_notice not in tool_contents[1] + assert level_1_notice not in tool_contents[2] + assert level_2_notice not in tool_contents[2] + assert level_1_notice in tool_contents[3] @pytest.mark.asyncio @@ -1084,7 +1105,9 @@ async def test_follow_up_accepted_when_active_and_not_stopping( ticket = runner.follow_up(message_text="valid follow-up message") - assert ticket is not None, "Follow-up should be accepted when runner is active and not stopping" + assert ticket is not None, ( + "Follow-up should be accepted when runner is active and not stopping" + ) assert ticket.text == "valid follow-up message" assert ticket.consumed is False assert ticket in runner._pending_follow_ups From 8be1e924183e609838bb8a0a0f97d0913121378f Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 16:05:46 +0800 Subject: [PATCH 3/5] chore: fix test --- tests/test_tool_loop_agent_runner.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 7558edd2bb..4a415752f2 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -600,17 +600,17 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( tool_contents = [str(message.content) for message in tool_messages] runner_cls = type(runner) - level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format( + level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD, + streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, ) - level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format( + level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD, + streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD, ) - level_3_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_3_TEMPLATE.format( + level_3_notice = runner_cls.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_3_THRESHOLD, + streak=runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD, ) assert level_1_notice not in tool_contents[0] @@ -664,13 +664,13 @@ async def test_same_tool_streak_resets_after_switching_tools( tool_contents = [str(message.content) for message in tool_messages] runner_cls = type(runner) - level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format( + level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD, + streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, ) - level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format( + level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD, + streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD, ) assert level_1_notice not in tool_contents[0] From 14e3576d79e2eabae549741470c0bff46638d08b Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 16:09:13 +0800 Subject: [PATCH 4/5] feat: rename guidance method for repeated tool calls to improve clarity --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index e4143b25cd..2b16bc32d8 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -479,7 +479,7 @@ def _track_tool_call_streak(self, tool_name: str) -> int: self._same_tool_streak = 1 return self._same_tool_streak - def _build_same_tool_guidance(self, tool_name: str, streak: int) -> str: + def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str: if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD: return "" @@ -936,7 +936,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: _append_tool_call_result( func_tool_id, "\n\n".join(result_parts) - + self._build_same_tool_guidance( + + self._build_repeated_tool_call_guidance( func_tool_name, tool_call_streak ), ) @@ -953,7 +953,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: _append_tool_call_result( func_tool_id, "The tool has no return value, or has sent the result directly to the user." - + self._build_same_tool_guidance( + + self._build_repeated_tool_call_guidance( func_tool_name, tool_call_streak ), ) @@ -965,7 +965,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: _append_tool_call_result( func_tool_id, "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*" - + self._build_same_tool_guidance( + + self._build_repeated_tool_call_guidance( func_tool_name, tool_call_streak ), ) @@ -986,7 +986,9 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: _append_tool_call_result( func_tool_id, f"error: {e!s}" - + self._build_same_tool_guidance(func_tool_name, tool_call_streak), + + self._build_repeated_tool_call_guidance( + func_tool_name, tool_call_streak + ), ) # yield the last tool call result From d37e9a8d143d15b61bd194d386a9080942203db0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Apr 2026 16:31:09 +0800 Subject: [PATCH 5/5] feat: update repeated tool notice thresholds to improve user guidance --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 2b16bc32d8..9d0b0ffce1 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -130,8 +130,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): "Do not return an empty response. " "Do not ignore the selected tools without explanation." ) - REPEATED_TOOL_NOTICE_L1_THRESHOLD = 2 - REPEATED_TOOL_NOTICE_L2_THRESHOLD = 3 + REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3 + REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4 REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5 REPEATED_TOOL_NOTICE_L1_TEMPLATE = ( "\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "