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..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 @@ -33,6 +32,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 +532,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