From 619c6ce70f9ffdacf9023e072fbdcb56ee6d8eae Mon Sep 17 00:00:00 2001 From: Praveen Mittal Date: Thu, 30 Jul 2026 23:13:52 +0200 Subject: [PATCH] fix: isolate toolset failures in SkillToolset additional-tools resolution _resolve_additional_tools_from_state used a bare asyncio.gather() over every provided toolset's get_tools_with_prefix() call. Since gather() propagates the first exception by default, one toolset failing to list its tools (e.g. an McpToolset whose server is unreachable) aborted resolution of every additional tool for that invocation, including plain BaseTool instances and healthy toolsets with no relationship to the failing one. Pass return_exceptions=True and skip (with a warning log) any toolset whose listing raised, while still re-raising CancelledError so cancellation continues to propagate. This confines a toolset failure to that toolset alone. Fixes #6507 --- src/google/adk/tools/skill_toolset.py | 24 +++++-- tests/unittests/tools/test_skill_toolset.py | 72 +++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) 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