diff --git a/src/google/adk/tools/skill_toolset.py b/src/google/adk/tools/skill_toolset.py index 126f35a8ca..5891b09c10 100644 --- a/src/google/adk/tools/skill_toolset.py +++ b/src/google/adk/tools/skill_toolset.py @@ -1288,12 +1288,24 @@ async def _resolve_additional_tools_from_state( # Collect all candidate tools from both individual tools and toolsets candidate_tools = self._provided_tools_by_name.copy() if self._provided_toolsets: - ts_results = await asyncio.gather(*( - ts.get_tools_with_prefix(readonly_context) - for ts in self._provided_toolsets - )) - for ts_tools in ts_results: - for t in ts_tools: + ts_results = await asyncio.gather( + *( + ts.get_tools_with_prefix(readonly_context) + for ts in self._provided_toolsets + ), + return_exceptions=True, + ) + for ts, ts_result in zip(self._provided_toolsets, ts_results): + if isinstance(ts_result, BaseException): + if isinstance(ts_result, asyncio.CancelledError): + raise ts_result + logger.warning( + "Skipping toolset %r: failed to list tools: %s", + ts, + ts_result, + ) + continue + for t in ts_result: candidate_tools[t.name] = t resolved_tools = [] diff --git a/tests/unittests/tools/test_skill_toolset.py b/tests/unittests/tools/test_skill_toolset.py index e92a4eb099..fc19f69ba5 100644 --- a/tests/unittests/tools/test_skill_toolset.py +++ b/tests/unittests/tools/test_skill_toolset.py @@ -297,6 +297,78 @@ async def test_resolve_additional_tools_from_state_none(mock_skill1): assert not result +@pytest.mark.asyncio +async def test_resolve_additional_tools_toolset_failure_does_not_abort_others( + mock_skill1, +): + """Regression test for https://github.com/google/adk-python/issues/6507. + + A provided toolset that raises while listing its tools must not abort + resolution of the other additional tools (individual tools and healthy + toolsets). + """ + mock_skill1.frontmatter.metadata = { + "adk_additional_tools": ["good_tool", "healthy_ts_tool", "bad_ts_tool"] + } + + good_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) + good_tool.name = "good_tool" + + healthy_ts_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True) + healthy_ts_tool.name = "healthy_ts_tool" + healthy_toolset = mock.create_autospec( + skill_toolset.BaseToolset, instance=True + ) + healthy_toolset.get_tools_with_prefix.return_value = [healthy_ts_tool] + + failing_toolset = mock.create_autospec( + skill_toolset.BaseToolset, instance=True + ) + failing_toolset.get_tools_with_prefix.side_effect = ConnectionError( + "MCP server unreachable (503)" + ) + + toolset = skill_toolset.SkillToolset( + [mock_skill1], + additional_tools=[good_tool, healthy_toolset, failing_toolset], + ) + + readonly_context = mock.create_autospec(ReadonlyContext, instance=True) + readonly_context.agent_name = "test_agent" + readonly_context.state.get.return_value = ["skill1"] + readonly_context.invocation_id = "inv1" + + result = await toolset._resolve_additional_tools_from_state(readonly_context) + + resolved_names = {t.name for t in result} + assert resolved_names == {"good_tool", "healthy_ts_tool"} + + +@pytest.mark.asyncio +async def test_resolve_additional_tools_reraises_cancelled_error(mock_skill1): + """A toolset raising CancelledError must still propagate cancellation.""" + mock_skill1.frontmatter.metadata = {"adk_additional_tools": ["ts_tool"]} + + cancelling_toolset = mock.create_autospec( + skill_toolset.BaseToolset, instance=True + ) + cancelling_toolset.get_tools_with_prefix.side_effect = ( + asyncio.CancelledError() + ) + + toolset = skill_toolset.SkillToolset( + [mock_skill1], additional_tools=[cancelling_toolset] + ) + + readonly_context = mock.create_autospec(ReadonlyContext, instance=True) + readonly_context.agent_name = "test_agent" + readonly_context.state.get.return_value = ["skill1"] + readonly_context.invocation_id = "inv1" + + with pytest.raises(asyncio.CancelledError): + await toolset._resolve_additional_tools_from_state(readonly_context) + + @pytest.mark.asyncio async def test_list_skills_tool( mock_skill1, mock_skill2, tool_context_instance