From 478f945c13a2fc860f710a4674d208a939b2b9a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:12:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=89=8D=E5=8D=A0=E4=BD=8D=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=8F=90=E5=89=8D=E5=8F=91=E9=80=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/runners/tool_loop_agent_runner.py | 9 +-- astrbot/core/astr_main_agent_resources.py | 10 +-- tests/test_tool_loop_agent_runner.py | 69 +++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 968426b8b4..c5a8126fab 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -787,11 +787,12 @@ async def step(self): ) return - if not llm_resp.tools_call_name: + has_tool_calls = bool(llm_resp.tools_call_name) + if not has_tool_calls: await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 - if llm_resp.reasoning_content: + if (not has_tool_calls) and llm_resp.reasoning_content: yield AgentResponse( type="llm_result", data=AgentResponseData( @@ -800,12 +801,12 @@ async def step(self): ), ), ) - if llm_resp.result_chain: + if (not has_tool_calls) and llm_resp.result_chain: yield AgentResponse( type="llm_result", data=AgentResponseData(chain=llm_resp.result_chain), ) - elif llm_resp.completion_text: + elif (not has_tool_calls) and llm_resp.completion_text: yield AgentResponse( type="llm_result", data=AgentResponseData( diff --git a/astrbot/core/astr_main_agent_resources.py b/astrbot/core/astr_main_agent_resources.py index 821ece702c..cfc40a3551 100644 --- a/astrbot/core/astr_main_agent_resources.py +++ b/astrbot/core/astr_main_agent_resources.py @@ -24,16 +24,18 @@ TOOL_CALL_PROMPT = ( "When using tools: " - "never return an empty response; " - "briefly explain the purpose before calling a tool; " + "you may return only tool calls when no user-facing message is needed; " + 'do not emit placeholder text such as "No response"; ' + "briefly explain the purpose before calling a tool only when it helps the user; " "follow the tool schema exactly and do not invent parameters; " "after execution, briefly summarize the result for the user; " "keep the conversation style consistent." ) TOOL_CALL_PROMPT_SKILLS_LIKE_MODE = ( - "You MUST NOT return an empty response, especially after invoking a tool." - " Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call." + "You may return only tool calls when no user-facing message is needed." + ' Do not emit placeholder text such as "No response".' + " Before calling any tool, provide a brief explanatory message to the user only when it helps." " Tool schemas are provided in two stages: first only name and description; " "if you decide to use a tool, the full parameter schema will be provided in " "a follow-up step. Do not guess arguments before you see the schema." diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 74d0691085..f6c12a8f2e 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -260,6 +260,31 @@ async def text_chat(self, **kwargs) -> LLMResponse: ) +class PreToolTextThenFinalProvider(MockProvider): + def __init__(self, pre_tool_text: str): + super().__init__() + self.pre_tool_text = pre_tool_text + + 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 > 1: + return LLMResponse( + role="assistant", + completion_text="final answer", + usage=TokenUsage(input_other=10, output=5), + ) + + return LLMResponse( + role="assistant", + completion_text=self.pre_tool_text, + tools_call_name=["test_tool"], + tools_call_args=[{"query": "test"}], + tools_call_ids=["call_pre_tool"], + usage=TokenUsage(input_other=10, output=5), + ) + + class SequentialToolProvider(MockProvider): def __init__(self, tool_sequence: list[str]): super().__init__() @@ -498,6 +523,50 @@ async def test_normal_completion_without_max_step( assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "pre_tool_text", + ["*No response*", "I will check this first."], +) +async def test_tool_call_turn_does_not_emit_pre_tool_llm_result(pre_tool_text: str): + tool = FunctionTool( + name="test_tool", + description="test tool", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + provider = PreToolTextThenFinalProvider(pre_tool_text) + request = ProviderRequest( + prompt="run tool", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + runner = ToolLoopAgentRunner() + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=cast(Any, MockToolExecutor()), + agent_hooks=MockHooks(), + streaming=False, + ) + + responses = [] + async for response in runner.step_until_done(3): + responses.append(response) + + llm_result_texts = [ + resp.data["chain"].get_plain_text(with_other_comps_mark=True) + for resp in responses + if resp.type == "llm_result" + ] + + assert pre_tool_text not in llm_result_texts + assert any(resp.type == "tool_call" for resp in responses) + assert "final answer" in llm_result_texts + + @pytest.mark.asyncio async def test_max_step_with_streaming( runner, mock_provider, provider_request, mock_tool_executor, mock_hooks From 104faf452b0ed459b252b410cbfed764b77ba2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:26:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E8=BD=AE=E6=AC=A1=E6=8E=A8=E7=90=86=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=B9=B6=E6=94=B6=E6=95=9B=E6=96=87=E6=9C=AC=E5=A4=96?= =?UTF-8?q?=E5=8F=91=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/runners/tool_loop_agent_runner.py | 27 +++++----- tests/test_tool_loop_agent_runner.py | 53 ++++++++++++++++++- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index c5a8126fab..6327760e44 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -792,7 +792,7 @@ async def step(self): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 - if (not has_tool_calls) and llm_resp.reasoning_content: + if llm_resp.reasoning_content: yield AgentResponse( type="llm_result", data=AgentResponseData( @@ -801,18 +801,19 @@ async def step(self): ), ), ) - if (not has_tool_calls) and llm_resp.result_chain: - yield AgentResponse( - type="llm_result", - data=AgentResponseData(chain=llm_resp.result_chain), - ) - elif (not has_tool_calls) and llm_resp.completion_text: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain().message(llm_resp.completion_text), - ), - ) + if not has_tool_calls: + if llm_resp.result_chain: + yield AgentResponse( + type="llm_result", + data=AgentResponseData(chain=llm_resp.result_chain), + ) + elif llm_resp.completion_text: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain().message(llm_resp.completion_text), + ), + ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index f6c12a8f2e..3f7bbf64ab 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -261,9 +261,12 @@ async def text_chat(self, **kwargs) -> LLMResponse: class PreToolTextThenFinalProvider(MockProvider): - def __init__(self, pre_tool_text: str): + def __init__( + self, pre_tool_text: str, reasoning_content: str | None = None + ): super().__init__() self.pre_tool_text = pre_tool_text + self.reasoning_content = reasoning_content async def text_chat(self, **kwargs) -> LLMResponse: self.call_count += 1 @@ -278,6 +281,7 @@ async def text_chat(self, **kwargs) -> LLMResponse: return LLMResponse( role="assistant", completion_text=self.pre_tool_text, + reasoning_content=self.reasoning_content, tools_call_name=["test_tool"], tools_call_args=[{"query": "test"}], tools_call_ids=["call_pre_tool"], @@ -567,6 +571,53 @@ async def test_tool_call_turn_does_not_emit_pre_tool_llm_result(pre_tool_text: s assert "final answer" in llm_result_texts +@pytest.mark.asyncio +async def test_tool_call_turn_still_emits_reasoning_content(): + tool = FunctionTool( + name="test_tool", + description="test tool", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + provider = PreToolTextThenFinalProvider( + pre_tool_text="*No response*", + reasoning_content="thinking...", + ) + request = ProviderRequest( + prompt="run tool", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + runner = ToolLoopAgentRunner() + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=cast(Any, MockToolExecutor()), + agent_hooks=MockHooks(), + streaming=False, + ) + + responses = [] + async for response in runner.step_until_done(3): + responses.append(response) + + reasoning_texts = [ + resp.data["chain"].get_plain_text(with_other_comps_mark=True) + for resp in responses + if resp.type == "llm_result" and resp.data["chain"].type == "reasoning" + ] + llm_result_texts = [ + resp.data["chain"].get_plain_text(with_other_comps_mark=True) + for resp in responses + if resp.type == "llm_result" + ] + + assert "thinking..." in reasoning_texts + assert "*No response*" not in llm_result_texts + + @pytest.mark.asyncio async def test_max_step_with_streaming( runner, mock_provider, provider_request, mock_tool_executor, mock_hooks