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/2] 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/2] 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