diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 968426b8b4..5bed38d3b5 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1076,15 +1076,13 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: ) _final_resp: CallToolResult | None = None + tool_result_parts: list[str] = [] async for resp in self._iter_tool_executor_results(executor): # type: ignore if isinstance(resp, CallToolResult): res = resp _final_resp = resp if not res.content: - _append_tool_call_result( - func_tool_id, - "The tool returned no content.", - ) + tool_result_parts.append("The tool returned no content.") continue result_parts: list[str] = [] @@ -1140,18 +1138,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: "The tool has returned a data type that is not supported." ) if result_parts: - inline_result = "\n\n".join(result_parts) - inline_result = await self._materialize_large_tool_result( - tool_call_id=func_tool_id, - content=inline_result, - ) - _append_tool_call_result( - func_tool_id, - inline_result - + self._build_repeated_tool_call_guidance( - func_tool_name, tool_call_streak - ), - ) + tool_result_parts.append("\n\n".join(result_parts)) elif resp is None: # Tool 直接请求发送消息给用户 @@ -1162,26 +1149,31 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: ) self._transition_state(AgentState.DONE) self.stats.end_time = time.time() - _append_tool_call_result( - func_tool_id, + tool_result_parts.append( "The tool has no return value, or has sent the result directly to the user." - + self._build_repeated_tool_call_guidance( - func_tool_name, tool_call_streak - ), ) else: # 不应该出现其他类型 logger.warning( f"Tool 返回了不支持的类型: {type(resp)}。", ) - _append_tool_call_result( - func_tool_id, + tool_result_parts.append( "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*" - + self._build_repeated_tool_call_guidance( - func_tool_name, tool_call_streak - ), ) + if tool_result_parts: + inline_result = await self._materialize_large_tool_result( + tool_call_id=func_tool_id, + content="\n\n".join(tool_result_parts), + ) + _append_tool_call_result( + func_tool_id, + inline_result + + self._build_repeated_tool_call_guidance( + func_tool_name, tool_call_streak + ), + ) + try: await self.agent_hooks.on_tool_end( self.run_context, diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index 74d0691085..609b3a455c 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -99,6 +99,22 @@ async def generator(): return generator() +class MultiYieldToolExecutor: + @classmethod + def execute(cls, tool, run_context, **tool_args): + async def generator(): + from mcp.types import CallToolResult, TextContent + + yield CallToolResult( + content=[TextContent(type="text", text="first partial result")] + ) + yield CallToolResult( + content=[TextContent(type="text", text="second partial result")] + ) + + return generator() + + class LargeTextToolExecutor: """模拟返回超长文本的工具执行器""" @@ -635,6 +651,41 @@ def fake_save_image( ] +@pytest.mark.asyncio +async def test_async_generator_tool_results_share_one_tool_call_id( + runner, mock_provider, provider_request, mock_hooks +): + """Multiple streamed tool results should be merged into one provider result.""" + + mock_provider.should_call_tools = True + mock_provider.max_calls_before_normal_response = 1 + + await runner.reset( + provider=mock_provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=MultiYieldToolExecutor, + agent_hooks=mock_hooks, + streaming=False, + ) + + async for _ in runner.step_until_done(3): + pass + + tool_messages = [ + m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" + ] + assert len(tool_messages) == 1 + assert tool_messages[0].tool_call_id == "call_123" + + content = str(tool_messages[0].content) + assert "first partial result" in content + assert "second partial result" in content + assert content.index("first partial result") < content.index( + "second partial result" + ) + + @pytest.mark.asyncio async def test_runner_replaces_runtime_image_context_before_provider_call( runner, provider_request, mock_hooks