diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index eb439c35435..4592f8c7169 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -352,8 +352,8 @@ "ContinuationToken", "ConversationSplit", "ConversationSplitter", - "Default", "DeduplicatingSkillsSource", + "Default", "DelegatingSkillsSource", "Edge", "EdgeCondition", diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 9dfb29932f9..0d85b1699ae 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -158,6 +158,22 @@ def streamable_http_client(*args: Any, **kwargs: Any) -> _AsyncGeneratorContextM return _streamable_http_client(*args, **kwargs) # type: ignore[return-value] +def _should_propagate_cancelled_error(ex: BaseException) -> bool: + """Return True if *ex* is a genuine task-cancellation that should propagate unchanged. + + On Python >= 3.11, ``task.cancelling() > 0`` distinguishes a real caller-driven + cancellation from a CancelledError raised internally by a library (e.g. via an + anyio cancel scope). On older Python versions the API is unavailable, so we + always return False and let callers wrap the error in ToolException instead. + """ + if not isinstance(ex, asyncio.CancelledError): + return False + if sys.version_info < (3, 11): + return False + task = asyncio.current_task() + return task is not None and task.cancelling() > 0 + + # region: MCP Plugin @@ -627,6 +643,17 @@ async def _safe_close_exit_stack(self) -> None: except asyncio.CancelledError: logger.warning("Could not cleanly close MCP exit stack because the lifecycle owner task was cancelled.") + async def _close_and_check_cancelled(self, ex: BaseException) -> bool: + """Close the exit stack and return True if *ex* is a genuine task cancellation. + + Callers should immediately re-raise when this returns True:: + + if await self._close_and_check_cancelled(ex): + raise + """ + await self._safe_close_exit_stack() + return _should_propagate_cancelled_error(ex) + async def connect(self, *, reset: bool = False) -> None: if self._is_lifecycle_owner_task(): await self._connect_on_owner(reset=reset) @@ -655,14 +682,23 @@ async def _connect_on_owner(self, *, reset: bool = False) -> None: if not self.session: try: transport = await self._exit_stack.enter_async_context(self.get_mcp_client()) - except Exception as ex: - await self._safe_close_exit_stack() + except (Exception, asyncio.CancelledError) as ex: + # On Python >= 3.11, re-raise genuine task cancellation (task.cancelling() > 0) + # instead of wrapping it in ToolException. On Python < 3.11, task.cancelling() + # is unavailable so MCP-internal CancelledErrors cannot be distinguished from + # caller-driven cancellation; they are wrapped as ToolException in that case. + if await self._close_and_check_cancelled(ex): + raise command = getattr(self, "command", None) if command: error_msg = f"Failed to start MCP server '{command}': {ex}" else: error_msg = f"Failed to connect to MCP server: {ex}" - raise ToolException(error_msg, inner_exception=ex) from ex + # CancelledError is a BaseException (not Exception) on Python >= 3.8, so + # inner_exception=None and ToolException.__init__ won't log exc_info. + if isinstance(ex, asyncio.CancelledError): + logger.debug(error_msg, exc_info=True) + raise ToolException(error_msg, inner_exception=ex if isinstance(ex, Exception) else None) from ex try: try: from mcp import types @@ -692,16 +728,21 @@ async def _connect_on_owner(self, *, reset: bool = False) -> None: sampling_capabilities=sampling_capabilities, ) ) - except Exception as ex: - await self._safe_close_exit_stack() + except (Exception, asyncio.CancelledError) as ex: + if await self._close_and_check_cancelled(ex): + raise + session_error_msg = f"Failed to create MCP session: {ex}" + if isinstance(ex, asyncio.CancelledError): + logger.debug(session_error_msg, exc_info=True) raise ToolException( - message="Failed to create MCP session. Please check your configuration.", - inner_exception=ex, + message=session_error_msg, + inner_exception=ex if isinstance(ex, Exception) else None, ) from ex try: await session.initialize() - except Exception as ex: - await self._safe_close_exit_stack() + except (Exception, asyncio.CancelledError) as ex: + if await self._close_and_check_cancelled(ex): + raise # Provide context about initialization failure command = getattr(self, "command", None) if command: @@ -710,7 +751,9 @@ async def _connect_on_owner(self, *, reset: bool = False) -> None: error_msg = f"MCP server '{full_command}' failed to initialize: {ex}" else: error_msg = f"MCP server failed to initialize: {ex}" - raise ToolException(error_msg, inner_exception=ex) from ex + if isinstance(ex, asyncio.CancelledError): + logger.debug(error_msg, exc_info=True) + raise ToolException(error_msg, inner_exception=ex if isinstance(ex, Exception) else None) from ex self.session = session elif self.session._request_id == 0: # type: ignore[attr-defined] # If the session is not initialized, we need to reinitialize it diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 082c6f1b699..06612b4df0a 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -446,14 +446,10 @@ async def run(self, skill: Skill, args: dict[str, Any] | None = None, **kwargs: """ if not isinstance(skill, FileSkill): raise TypeError( - f"File-based script '{self.name}' requires a FileSkill " - f"but received '{type(skill).__name__}'." + f"File-based script '{self.name}' requires a FileSkill but received '{type(skill).__name__}'." ) if self._runner is None: - raise ValueError( - f"Script '{self.name}' requires a runner. " - "Provide a script_runner for file-based scripts." - ) + raise ValueError(f"Script '{self.name}' requires a runner. Provide a script_runner for file-based scripts.") result = self._runner(skill, self, args) if inspect.isawaitable(result): return await result @@ -570,8 +566,7 @@ def _validate_skill_description(name: str, description: str) -> None: raise ValueError("Skill description cannot be empty.") if len(description) > MAX_DESCRIPTION_LENGTH: raise ValueError( - f"Skill '{name}' has an invalid description: " - f"Must be {MAX_DESCRIPTION_LENGTH} characters or fewer." + f"Skill '{name}' has an invalid description: Must be {MAX_DESCRIPTION_LENGTH} characters or fewer." ) @@ -1993,10 +1988,7 @@ def _get_validated_resource_path(skill_dir: str, resource_name: str) -> str: raise ValueError(f"Resource file '{resource_name}' not found in skill directory '{skill_dir}'.") if FileSkillsSource._has_symlink_in_path(resource_full_path, root_directory_path): - raise ValueError( - f"Resource file '{resource_name}' " - "has a symlink in its path; symlinks are not allowed." - ) + raise ValueError(f"Resource file '{resource_name}' has a symlink in its path; symlinks are not allowed.") return resource_full_path diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 01cf1717bdd..487331e3f0d 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -1,8 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. # type: ignore[reportPrivateUsage] +import asyncio import json import logging import os +import sys from contextlib import _AsyncGeneratorContextManager # type: ignore from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -27,6 +29,7 @@ _build_prefixed_mcp_name, _get_input_model_from_mcp_prompt, _normalize_mcp_name, + _should_propagate_cancelled_error, logger, ) from agent_framework._middleware import FunctionMiddlewarePipeline @@ -2176,6 +2179,7 @@ async def test_connect_session_creation_failure(): await tool.connect() assert "Failed to create MCP session" in str(exc_info.value) + assert "Session creation failed" in str(exc_info.value) # exception text is now part of the message assert "Session creation failed" in str(exc_info.value.__cause__) @@ -2264,6 +2268,282 @@ async def test_connect_cleanup_on_initialization_failure(): tool._exit_stack.aclose.assert_called_once() +async def test_connect_cancelled_error_during_transport_creation_raises_tool_exception(): + """Test that CancelledError from transport creation is wrapped in ToolException.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + tool._exit_stack.aclose = AsyncMock() + tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("cancel scope")) + + with pytest.raises(ToolException, match="Failed to connect to MCP server"): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + +async def test_connect_cancelled_error_during_transport_creation_stdio_raises_tool_exception(): + """Test that CancelledError from transport creation uses the command-specific message for MCPStdioTool.""" + tool = MCPStdioTool(name="test", command="my-server") + tool._exit_stack.aclose = AsyncMock() + tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("cancel scope")) + + with pytest.raises(ToolException, match="Failed to start MCP server 'my-server'"): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + +async def test_connect_cancelled_error_during_session_creation_raises_tool_exception(): + """Test that CancelledError from session creation is wrapped in ToolException.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("cancel scope")) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(ToolException, match="Failed to create MCP session"): + await tool.connect() + + +async def test_connect_cancelled_error_during_initialize_raises_tool_exception(): + """Test that CancelledError from session.initialize() is wrapped in ToolException. + + This is the primary regression test for the bug: when an MCP server is unreachable, + the MCP library raises asyncio.CancelledError internally, which previously escaped + all except Exception handlers and could not be caught by user code. + """ + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + mock_session = Mock() + mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope")) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(ToolException, match="MCP server failed to initialize"): + await tool.connect() + + +async def test_connect_cancelled_error_during_initialize_stdio_raises_tool_exception(): + """Test that CancelledError from session.initialize() uses the command-specific message for MCPStdioTool.""" + tool = MCPStdioTool(name="test", command="my-server", args=["--port", "8080"]) + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + mock_session = Mock() + mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope")) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(ToolException, match="MCP server 'my-server --port 8080' failed to initialize"): + await tool.connect() + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11") +async def test_connect_genuine_cancellation_during_transport_creation_propagates(): + """Test that genuine task cancellation (task.cancelling() > 0) propagates as CancelledError.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + tool._exit_stack.aclose = AsyncMock() + + mock_cancelled_task = Mock() + mock_cancelled_task.cancelling.return_value = 1 + + with patch("asyncio.current_task", return_value=mock_cancelled_task): + tool.get_mcp_client = Mock(side_effect=asyncio.CancelledError("task cancelled")) + with pytest.raises(asyncio.CancelledError): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11") +async def test_connect_genuine_cancellation_during_initialize_propagates(): + """Test that genuine task cancellation during initialize() propagates as CancelledError.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + tool._exit_stack.aclose = AsyncMock() + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + mock_session = Mock() + mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("task cancelled")) + + mock_cancelled_task = Mock() + mock_cancelled_task.cancelling.return_value = 1 + + with ( + patch("asyncio.current_task", return_value=mock_cancelled_task), + patch("mcp.client.session.ClientSession") as mock_session_class, + ): + mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(asyncio.CancelledError): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11") +async def test_connect_genuine_cancellation_during_session_creation_propagates(): + """Test that genuine task cancellation during session creation propagates as CancelledError.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + tool._exit_stack.aclose = AsyncMock() + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + mock_cancelled_task = Mock() + mock_cancelled_task.cancelling.return_value = 1 + + with ( + patch("asyncio.current_task", return_value=mock_cancelled_task), + patch("mcp.client.session.ClientSession") as mock_session_class, + ): + mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("task cancelled")) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(asyncio.CancelledError): + await tool.connect() + + tool._exit_stack.aclose.assert_called_once() + + +async def test_aenter_cancelled_error_during_connect_is_catchable_as_exception(): + """Test that CancelledError during __aenter__ is catchable as Exception. + + Verifies the end-to-end fix: async with MCPStreamableHTTPTool(...) raises an + exception that can be caught by a normal `except Exception` block. + """ + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + + mock_session = Mock() + mock_session.initialize = AsyncMock(side_effect=asyncio.CancelledError("Cancelled via cancel scope")) + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + caught = None + try: + async with tool: + pass + except Exception as e: + caught = e + + assert caught is not None, "Expected an exception to be caught by except Exception" + assert isinstance(caught, ToolException) + + +# Tests for _should_propagate_cancelled_error helper + + +def test_should_propagate_cancelled_error_returns_false_for_non_cancelled_error(): + assert _should_propagate_cancelled_error(RuntimeError("boom")) is False + + +def test_should_propagate_cancelled_error_returns_false_when_no_current_task(): + with patch("asyncio.current_task", return_value=None): + assert _should_propagate_cancelled_error(asyncio.CancelledError()) is False + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11") +def test_should_propagate_cancelled_error_returns_true_when_task_is_cancelling(): + mock_task = Mock() + mock_task.cancelling.return_value = 1 + with patch("asyncio.current_task", return_value=mock_task): + assert _should_propagate_cancelled_error(asyncio.CancelledError()) is True + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="task.cancelling() requires Python >= 3.11") +def test_should_propagate_cancelled_error_returns_false_when_task_not_cancelling(): + mock_task = Mock() + mock_task.cancelling.return_value = 0 + with patch("asyncio.current_task", return_value=mock_task): + assert _should_propagate_cancelled_error(asyncio.CancelledError()) is False + + +async def test_connect_cancelled_error_during_session_creation_includes_exception_in_message(): + """Test that CancelledError from session creation includes exception details in ToolException message.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock( + side_effect=asyncio.CancelledError("cancel scope detail") + ) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + with pytest.raises(ToolException) as exc_info: + await tool.connect() + + assert "Failed to create MCP session" in str(exc_info.value) + assert "cancel scope detail" in str(exc_info.value) + + +async def test_connect_cancelled_error_during_session_creation_logs_with_exc_info(): + """Test that CancelledError from session creation is logged with exc_info=True.""" + tool = MCPStreamableHTTPTool(name="test", url="http://example.com") + + mock_transport = (Mock(), Mock()) + mock_context_manager = Mock() + mock_context_manager.__aenter__ = AsyncMock(return_value=mock_transport) + mock_context_manager.__aexit__ = AsyncMock(return_value=None) + tool.get_mcp_client = Mock(return_value=mock_context_manager) + + with patch("mcp.client.session.ClientSession") as mock_session_class: + mock_session_class.return_value.__aenter__ = AsyncMock(side_effect=asyncio.CancelledError("cancel scope")) + mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) + + from agent_framework._mcp import logger as mcp_logger + + with patch.object(mcp_logger, "debug") as mock_debug: + with pytest.raises(ToolException): + await tool.connect() + + # Verify logger.debug was called with exc_info=True (not an exception instance) + debug_calls = mock_debug.call_args_list + cancel_calls = [c for c in debug_calls if "Failed to create MCP session" in str(c)] + assert cancel_calls, "Expected a debug log for the cancelled session creation" + _, kwargs = cancel_calls[0] + assert kwargs.get("exc_info") is True + + def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs(): """Test MCPStdioTool.get_mcp_client() with environment variables and client kwargs.""" env_vars = {"PATH": "/usr/bin", "DEBUG": "1"} diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 36906d55b86..8e8c6a8aedf 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -1190,7 +1190,9 @@ async def get_user_data(**kwargs: Any) -> Any: provider = SkillsProvider([skill]) await _init_provider(provider) - result = await provider._read_skill_resource(_raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc") + result = await provider._read_skill_resource( + _raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc" + ) assert result == "data with token=abc" async def test_read_callable_resource_without_kwargs_ignores_extra_args(self) -> None: @@ -2059,6 +2061,7 @@ async def test_read_sync_function(self) -> None: async def test_read_async_function(self) -> None: """read() awaits an async function and returns its result.""" + async def get_data() -> str: return "async result" @@ -2068,6 +2071,7 @@ async def get_data() -> str: async def test_read_function_with_kwargs(self) -> None: """read() forwards kwargs to functions that accept them.""" + def get_config(**kwargs: Any) -> str: return f"user={kwargs.get('user_id')}" @@ -2077,6 +2081,7 @@ def get_config(**kwargs: Any) -> str: async def test_read_async_function_with_kwargs(self) -> None: """read() forwards kwargs to async functions that accept them.""" + async def get_config(**kwargs: Any) -> str: return f"user={kwargs.get('user_id')}" @@ -2086,6 +2091,7 @@ async def get_config(**kwargs: Any) -> str: async def test_read_function_without_kwargs_ignores_extra(self) -> None: """read() does not pass kwargs to functions that don't accept them.""" + def simple() -> str: return "fixed" @@ -2095,6 +2101,7 @@ def simple() -> str: async def test_read_function_raises_propagates(self) -> None: """read() propagates exceptions from the function.""" + def failing() -> str: raise RuntimeError("boom") @@ -2747,6 +2754,7 @@ async def async_func(x: int = 0) -> str: async def test_code_script_returns_object(self) -> None: """Code-defined scripts can return non-string objects.""" + def returns_dict() -> dict: return {"status": "ok", "value": 42} @@ -2855,8 +2863,8 @@ def process(**kwargs: Any) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) - result = await provider._run_skill_script(_raw_skills(provider), - "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value" + result = await provider._run_skill_script( + _raw_skills(provider), "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value" ) assert "Error" in result @@ -2946,6 +2954,7 @@ async def test_require_script_approval_does_not_affect_other_tools(self) -> None async def test_code_script_exception_returns_error(self) -> None: """A code script function that raises should return an error string.""" + def failing_script() -> str: raise RuntimeError("Something went wrong") @@ -3170,6 +3179,7 @@ async def test_code_skill_no_scripts_element(self) -> None: async def test_code_skill_scripts_element_contains_parameters(self) -> None: """Scripts XML includes parameters schema when the function has typed parameters.""" + def analyze(query: str, limit: int = 10) -> str: return "result" @@ -3755,9 +3765,7 @@ async def test_file_source_with_script_runner(self, tmp_path: Path) -> None: ) (skill_dir / "run.py").write_text("print('hi')", encoding="utf-8") - source = DeduplicatingSkillsSource( - FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner) - ) + source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=_noop_script_runner)) provider = SkillsProvider(source) await _init_provider(provider) assert "my-skill" in _ctx(provider)[0] @@ -3798,9 +3806,7 @@ async def source_runner(skill: Any, script: Any, args: Any = None) -> str: call_log.append("source") return "source" - source = DeduplicatingSkillsSource( - FileSkillsSource(str(tmp_path), script_runner=source_runner) - ) + source = DeduplicatingSkillsSource(FileSkillsSource(str(tmp_path), script_runner=source_runner)) provider = SkillsProvider(source) await _init_provider(provider)