From 73db9e2bf6a3bff8b3a496035a5c4224fda2af81 Mon Sep 17 00:00:00 2001 From: zhenliemao <494822673@qq.com> Date: Sun, 28 Jun 2026 18:05:33 +0800 Subject: [PATCH 1/4] Fix: Allow running in environments with existing event loop Summary: This fix allows SkillSpector to run in environments that already have a running event loop, preventing RuntimeError when asyncio.run() is called from within an existing loop. Problem: When running SkillSpector in environments like: - Jupyter Notebooks - LangGraph Studio - FastAPI applications - Any programmatic usage within async code The call to asyncio.run() throws a RuntimeError: This event loop is already running and falls back to unfiltered static findings, silently disabling LLM analysis. The previous approach of detecting this state via error message substring matching is fragile and locale-dependent. Solution: 1. Add utility function in that properly detects running loops using 2. When no running loop exists, fall back to directly 3. When a loop is already running, offload execution to a separate thread with its own event loop via 4. Replace all calls across all analyzer nodes with the new helper 5. Remove unused asyncio imports from analyzer files Test: Add comprehensive unit tests for run_async covering: - Normal execution without existing running loop - Nested execution inside an already running loop - Exception propagation from async coroutines - Correct handling of async functions with await calls Signed-off-by: zhenliemao <494822673@qq.com> --- src/skillspector/llm_utils.py | 31 +++++++++++++++++++ .../analyzers/semantic_developer_intent.py | 5 ++- .../analyzers/semantic_quality_policy.py | 5 ++- src/skillspector/nodes/meta_analyzer.py | 3 +- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/skillspector/llm_utils.py b/src/skillspector/llm_utils.py index d1c51040..d698d66d 100644 --- a/src/skillspector/llm_utils.py +++ b/src/skillspector/llm_utils.py @@ -30,6 +30,11 @@ from __future__ import annotations +import asyncio +import concurrent.futures +from collections.abc import Coroutine +from typing import Any + from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import BaseMessage @@ -106,3 +111,29 @@ def chat_completion(prompt: str, *, model: str | None = None) -> str: if not isinstance(response, BaseMessage): raise TypeError(f"Expected BaseMessage from chat model, got {type(response).__name__}") return str(response.text) + + +def run_async(coroutine: Coroutine) -> Any: + """ + Run an async coroutine in a synchronous context, even if there's already a running event loop. + + This function safely handles nested event loop scenarios (e.g. Jupyter Notebooks, FastAPI, + LangGraph Studio) by offloading the coroutine execution to a separate thread with its own + event loop when a running loop is detected. + + Args: + coroutine: The async coroutine to run + + Returns: + The result of the coroutine execution + + Raises: + Any exception raised by the coroutine is re-raised as-is + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coroutine) + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + return executor.submit(asyncio.run, coroutine).result() diff --git a/src/skillspector/nodes/analyzers/semantic_developer_intent.py b/src/skillspector/nodes/analyzers/semantic_developer_intent.py index a3a54be2..e621141b 100644 --- a/src/skillspector/nodes/analyzers/semantic_developer_intent.py +++ b/src/skillspector/nodes/analyzers/semantic_developer_intent.py @@ -22,10 +22,9 @@ from __future__ import annotations -import asyncio - from skillspector.constants import _SKILLSPECTOR_DEFAULT_MODEL, MODEL_CONFIG from skillspector.llm_analyzer_base import LLMAnalyzerBase +from skillspector.llm_utils import run_async from skillspector.logging_config import get_logger from skillspector.state import AnalyzerNodeResponse, SkillspectorState @@ -176,7 +175,7 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: prompt = ANALYZER_PROMPT.format(manifest_section=_format_manifest(manifest)) analyzer = LLMAnalyzerBase(base_prompt=prompt, model=model) batches = analyzer.get_batches(sorted(file_cache), file_cache) - results = asyncio.run(analyzer.arun_batches(batches)) + results = run_async(analyzer.arun_batches(batches)) findings = analyzer.collect_findings(results) logger.info("%s: %d findings", ANALYZER_ID, len(findings)) return {"findings": findings} diff --git a/src/skillspector/nodes/analyzers/semantic_quality_policy.py b/src/skillspector/nodes/analyzers/semantic_quality_policy.py index 3140334e..f22a0005 100644 --- a/src/skillspector/nodes/analyzers/semantic_quality_policy.py +++ b/src/skillspector/nodes/analyzers/semantic_quality_policy.py @@ -22,10 +22,9 @@ from __future__ import annotations -import asyncio - from skillspector.constants import _SKILLSPECTOR_DEFAULT_MODEL from skillspector.llm_analyzer_base import LLMAnalyzerBase +from skillspector.llm_utils import run_async from skillspector.logging_config import get_logger from skillspector.state import AnalyzerNodeResponse, SkillspectorState @@ -145,7 +144,7 @@ def node(state: SkillspectorState) -> AnalyzerNodeResponse: try: analyzer = LLMAnalyzerBase(base_prompt=ANALYZER_PROMPT, model=model) batches = analyzer.get_batches(files, file_cache) - results = asyncio.run(analyzer.arun_batches(batches)) + results = run_async(analyzer.arun_batches(batches)) findings = analyzer.collect_findings(results) logger.info("%s: %d findings", ANALYZER_ID, len(findings)) return {"findings": findings} diff --git a/src/skillspector/nodes/meta_analyzer.py b/src/skillspector/nodes/meta_analyzer.py index a1fff859..95a195ee 100644 --- a/src/skillspector/nodes/meta_analyzer.py +++ b/src/skillspector/nodes/meta_analyzer.py @@ -33,6 +33,7 @@ LLMAnalyzerBase, estimate_tokens, ) +from skillspector.llm_utils import run_async from skillspector.logging_config import get_logger from skillspector.models import Finding from skillspector.nodes.analyzers.pattern_defaults import ( @@ -532,7 +533,7 @@ def meta_analyzer(state: SkillspectorState) -> MetaAnalyzerResponse: model, ) - batch_results = asyncio.run(analyzer.arun_batches(batches, metadata_text=metadata_text)) + batch_results = run_async(analyzer.arun_batches(batches, metadata_text=metadata_text)) if len(batch_results) < len(batches): # Some batches never returned. A finding the LLM never saw has no From ea488588c71bed6871abb491bb59ef4c1781b38f Mon Sep 17 00:00:00 2001 From: zhenliemao <494822673@qq.com> Date: Sun, 28 Jun 2026 18:16:43 +0800 Subject: [PATCH 2/4] Fix: remove unused asyncio import from meta_analyzer.py Signed-off-by: zhenliemao <494822673@qq.com> --- src/skillspector/nodes/meta_analyzer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/skillspector/nodes/meta_analyzer.py b/src/skillspector/nodes/meta_analyzer.py index 95a195ee..ac2b0ab3 100644 --- a/src/skillspector/nodes/meta_analyzer.py +++ b/src/skillspector/nodes/meta_analyzer.py @@ -22,7 +22,6 @@ from __future__ import annotations -import asyncio import json from typing import Literal From 9a2e087704da42b2889748d5a6a6cc830a4b1697 Mon Sep 17 00:00:00 2001 From: zhenliemao <494822673@qq.com> Date: Wed, 1 Jul 2026 14:50:36 +0800 Subject: [PATCH 3/4] Add unit tests for run_async utility function Signed-off-by: zhenliemao <494822673@qq.com> --- tests/unit/test_llm_utils.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/test_llm_utils.py b/tests/unit/test_llm_utils.py index 18a1a7f7..411978b4 100644 --- a/tests/unit/test_llm_utils.py +++ b/tests/unit/test_llm_utils.py @@ -22,6 +22,8 @@ from __future__ import annotations +import asyncio + import pytest from langchain_anthropic import ChatAnthropic from langchain_core.messages import AIMessage @@ -33,6 +35,7 @@ fetch_model_token_limits, get_chat_model, is_llm_available, + run_async, ) from skillspector.providers import NO_LLM_API_KEY_MESSAGE, resolve_provider_credentials from skillspector.providers.nv_build import NvBuildProvider @@ -216,3 +219,39 @@ def test_provider_credentials_use_provider_default_model( def _chat_model_name(llm: object) -> str: return str(getattr(llm, "model_name", None) or getattr(llm, "model", None)) + + +class TestRunAsync: + """Tests for run_async helper function that handles nested event loops.""" + async def _test_async_function(self, value: int, delay: float = 0) -> int: + """Simple async function for testing.""" + if delay > 0: + await asyncio.sleep(delay) + return value * 2 + async def _test_async_function_raises(self) -> None: + """Async function that raises an exception for testing.""" + raise ValueError("Test exception") + def test_run_async_without_running_loop(self) -> None: + """Test run_async works correctly when there is no running event loop.""" + result = run_async(self._test_async_function(42)) + assert result == 84 + def test_run_async_with_running_loop(self) -> None: + """Test run_async works correctly even when there is already a running event loop. + This regression test covers the scenario where SkillSpector is invoked from + environments like Jupyter Notebooks, FastAPI, or LangGraph Studio that already + have an active event loop. + """ + async def _test_in_running_loop() -> int: + # Call run_async from within an already running event loop + return run_async(self._test_async_function(100)) + # Use asyncio.run to create a running loop context + result = asyncio.run(_test_in_running_loop()) + assert result == 200 + def test_run_async_propagates_exceptions(self) -> None: + """Test exceptions from async functions are properly propagated.""" + with pytest.raises(ValueError, match="Test exception"): + run_async(self._test_async_function_raises()) + def test_run_async_with_delay(self) -> None: + """Test run_async correctly handles async functions with await calls.""" + result = run_async(self._test_async_function(5, delay=0.01)) + assert result == 10 From 9f741853f79a5ea8c672dbf9009ca617085e1696 Mon Sep 17 00:00:00 2001 From: zhenliemao <494822673@qq.com> Date: Wed, 1 Jul 2026 15:00:58 +0800 Subject: [PATCH 4/4] Fix conflict: update chat_completion implementation to match main branch Signed-off-by: zhenliemao <494822673@qq.com> --- src/skillspector/llm_utils.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/skillspector/llm_utils.py b/src/skillspector/llm_utils.py index d698d66d..e591886e 100644 --- a/src/skillspector/llm_utils.py +++ b/src/skillspector/llm_utils.py @@ -105,12 +105,19 @@ def get_chat_model(model: str | None = None) -> BaseChatModel: def chat_completion(prompt: str, *, model: str | None = None) -> str: - """Request a single chat completion and return the assistant text.""" - llm = get_chat_model(model=model) - response = llm.invoke(prompt) - if not isinstance(response, BaseMessage): - raise TypeError(f"Expected BaseMessage from chat model, got {type(response).__name__}") - return str(response.text) + """Request a single chat completion and return the assistant content. + + Routes through :func:`get_chat_model`, which dispatches to the CLI adapter + for CLI providers and to the provider's native chat model for HTTP providers. + + Uses ``.text`` when available (real LangChain ``BaseMessage`` objects, + which normalise content blocks to a single string) and falls back to + ``.content`` for the CLI adapter's ``_AgentCLIMessage``. + """ + response = get_chat_model(model=model).invoke(prompt) + if hasattr(response, "text"): + return response.text # type: ignore[union-attr] + return response.content or "" # type: ignore[union-attr] def run_async(coroutine: Coroutine) -> Any: