From 799f9cc4f9cb08a267c3f02aad88dabfa2aca926 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 8 Jun 2026 12:58:51 -0700 Subject: [PATCH 1/3] fix(rag): don't let a broken native dep crash every agent import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A broken native dependency under the optional RAG stack — most commonly torchcodec/FFmpeg pulled in transitively by sentence-transformers, but also an arch-mismatched faiss build — raises RuntimeError/OSError at import, not ImportError. The guard in rag/sdk.py only caught ImportError, so the exception escaped and killed the entire import chain: importing gaia.agents.chat (or any agent that transitively imports RAG) died at module load even when RAG was never used, taking `gaia chat` and friends down with it. Broaden the sentence-transformers and faiss guards to treat any import failure as "not installed", and capture the failure reason so the loud, deferred error in RAGSDK._check_dependencies() distinguishes "not installed" (reinstall) from "installed but broken" (fix the underlying native dep, e.g. FFmpeg) instead of misdirecting the user. --- src/gaia/rag/sdk.py | 32 +++++++++++++- tests/unit/rag/test_dependency_guard.py | 59 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/unit/rag/test_dependency_guard.py diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 81e751bed..8b023dee8 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -30,15 +30,26 @@ except ImportError: PdfReader = None +# Not just ImportError: a broken native dependency (e.g. torchcodec/FFmpeg +# pulled in by sentence-transformers, or an arch-mismatched faiss build) raises +# RuntimeError/OSError at import. Treat that the same as "not installed" so a +# bad install can't crash every module that transitively imports RAG; the loud, +# actionable error is deferred to RAGSDK._check_dependencies() at point of use. +# Capture the reason so that error can tell "not installed" from "installed but +# broken" instead of misdirecting the user to reinstall a package they have. +_SENTENCE_TRANSFORMERS_IMPORT_ERROR = None try: from sentence_transformers import SentenceTransformer -except ImportError: +except Exception as _e: # pylint: disable=broad-except SentenceTransformer = None + _SENTENCE_TRANSFORMERS_IMPORT_ERROR = _e +_FAISS_IMPORT_ERROR = None try: import faiss -except ImportError: +except Exception as _e: # pylint: disable=broad-except faiss = None + _FAISS_IMPORT_ERROR = _e from gaia.chat.sdk import AgentConfig, AgentSDK from gaia.logger import get_logger @@ -225,6 +236,23 @@ def _check_dependencies(self): f"Or install packages directly:\n" f" uv pip install {' '.join(missing)}\n" ) + # A package that is installed but failed to import (broken native + # deps) needs a different fix than a missing one — name the cause. + broken = [] + if SentenceTransformer is None and _SENTENCE_TRANSFORMERS_IMPORT_ERROR: + broken.append( + f" sentence-transformers: {_SENTENCE_TRANSFORMERS_IMPORT_ERROR}" + ) + if faiss is None and _FAISS_IMPORT_ERROR: + broken.append(f" faiss: {_FAISS_IMPORT_ERROR}") + if broken: + error_msg += ( + "\nThe package(s) below are installed but failed to load — " + "reinstalling won't help until the underlying error is fixed " + "(e.g. a missing FFmpeg for torchcodec):\n" + + "\n".join(broken) + + "\n" + ) raise ImportError(error_msg) def _safe_open(self, file_path: str, mode="rb"): diff --git a/tests/unit/rag/test_dependency_guard.py b/tests/unit/rag/test_dependency_guard.py new file mode 100644 index 000000000..7a9499782 --- /dev/null +++ b/tests/unit/rag/test_dependency_guard.py @@ -0,0 +1,59 @@ +# Copyright(C) 2024-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Unit tests for RAG dependency-import guards. + +A broken native dependency (e.g. torchcodec/FFmpeg under sentence-transformers, +or an arch-mismatched faiss build) raises ``RuntimeError``/``OSError`` at import +rather than ``ImportError``. The guard in ``gaia.rag.sdk`` must treat that the +same as "not installed" so it cannot crash every module that transitively +imports RAG, while still surfacing a loud, actionable error at point of use. +""" + +import importlib +from unittest.mock import patch + +import pytest + +# Importing the module must NOT raise even when an optional native dep is broken +# in the environment — that is the regression this guard protects against. +sdk = importlib.import_module("gaia.rag.sdk") + + +def _bare_sdk(): + """An RAGSDK instance without running __init__ (it only needs the method).""" + return sdk.RAGSDK.__new__(sdk.RAGSDK) + + +def test_module_imports_without_optional_deps(): + """The module is importable regardless of optional-dependency health.""" + assert hasattr(sdk, "RAGSDK") + assert hasattr(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR") + assert hasattr(sdk, "_FAISS_IMPORT_ERROR") + + +def test_broken_install_reports_actionable_cause(): + """An installed-but-broken dep surfaces the captured cause, not just 'install it'.""" + cause = RuntimeError("Could not load libtorchcodec (FFmpeg not found)") + with ( + patch.object(sdk, "SentenceTransformer", None), + patch.object(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR", cause), + ): + with pytest.raises(ImportError) as excinfo: + _bare_sdk()._check_dependencies() + msg = str(excinfo.value) + assert "installed but failed to load" in msg + assert "libtorchcodec" in msg # the captured cause is named + + +def test_genuinely_missing_dep_omits_broken_section(): + """A simply-missing dep gets install instructions, not the broken-load hint.""" + with ( + patch.object(sdk, "SentenceTransformer", None), + patch.object(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR", None), + ): + with pytest.raises(ImportError) as excinfo: + _bare_sdk()._check_dependencies() + msg = str(excinfo.value) + assert "sentence-transformers" in msg + assert "installed but failed to load" not in msg From 11a3767a0395a8234c035a262331117caad0a065 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 8 Jun 2026 13:20:05 -0700 Subject: [PATCH 2/3] fix(rag): capture broken-dep cause lazily to satisfy import-position lint The module-level error-capture assignments tripped pylint C0413 (wrong-import-position). Recover the failure reason inside _check_dependencies() via importlib instead, keeping the module's import block clean. Behaviour is unchanged. --- src/gaia/rag/sdk.py | 33 ++++++++++------- tests/unit/rag/test_dependency_guard.py | 49 ++++++++++++++++--------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 8b023dee8..a1b404147 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -9,6 +9,7 @@ import errno import hashlib import hmac +import importlib import json import os import re @@ -35,21 +36,15 @@ # RuntimeError/OSError at import. Treat that the same as "not installed" so a # bad install can't crash every module that transitively imports RAG; the loud, # actionable error is deferred to RAGSDK._check_dependencies() at point of use. -# Capture the reason so that error can tell "not installed" from "installed but -# broken" instead of misdirecting the user to reinstall a package they have. -_SENTENCE_TRANSFORMERS_IMPORT_ERROR = None try: from sentence_transformers import SentenceTransformer -except Exception as _e: # pylint: disable=broad-except +except Exception: # pylint: disable=broad-except SentenceTransformer = None - _SENTENCE_TRANSFORMERS_IMPORT_ERROR = _e -_FAISS_IMPORT_ERROR = None try: import faiss -except Exception as _e: # pylint: disable=broad-except +except Exception: # pylint: disable=broad-except faiss = None - _FAISS_IMPORT_ERROR = _e from gaia.chat.sdk import AgentConfig, AgentSDK from gaia.logger import get_logger @@ -238,13 +233,23 @@ def _check_dependencies(self): ) # A package that is installed but failed to import (broken native # deps) needs a different fix than a missing one — name the cause. + # Re-import on this (already-failing) path to recover the reason, + # skipping genuinely-missing packages (ImportError) which the + # install instructions above already cover. broken = [] - if SentenceTransformer is None and _SENTENCE_TRANSFORMERS_IMPORT_ERROR: - broken.append( - f" sentence-transformers: {_SENTENCE_TRANSFORMERS_IMPORT_ERROR}" - ) - if faiss is None and _FAISS_IMPORT_ERROR: - broken.append(f" faiss: {_FAISS_IMPORT_ERROR}") + for pkg, label in ( + ("sentence_transformers", "sentence-transformers"), + ("faiss", "faiss"), + ): + if (pkg == "sentence_transformers" and SentenceTransformer is None) or ( + pkg == "faiss" and faiss is None + ): + try: + importlib.import_module(pkg) + except ImportError: + pass # genuinely missing → covered by install instructions + except Exception as exc: # pylint: disable=broad-except + broken.append(f" {label}: {exc}") if broken: error_msg += ( "\nThe package(s) below are installed but failed to load — " diff --git a/tests/unit/rag/test_dependency_guard.py b/tests/unit/rag/test_dependency_guard.py index 7a9499782..7e3acffeb 100644 --- a/tests/unit/rag/test_dependency_guard.py +++ b/tests/unit/rag/test_dependency_guard.py @@ -10,8 +10,8 @@ imports RAG, while still surfacing a loud, actionable error at point of use. """ +import builtins import importlib -from unittest.mock import patch import pytest @@ -28,32 +28,45 @@ def _bare_sdk(): def test_module_imports_without_optional_deps(): """The module is importable regardless of optional-dependency health.""" assert hasattr(sdk, "RAGSDK") - assert hasattr(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR") - assert hasattr(sdk, "_FAISS_IMPORT_ERROR") + assert hasattr(sdk, "SentenceTransformer") + assert hasattr(sdk, "faiss") -def test_broken_install_reports_actionable_cause(): +def test_broken_install_reports_actionable_cause(monkeypatch): """An installed-but-broken dep surfaces the captured cause, not just 'install it'.""" - cause = RuntimeError("Could not load libtorchcodec (FFmpeg not found)") - with ( - patch.object(sdk, "SentenceTransformer", None), - patch.object(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR", cause), - ): - with pytest.raises(ImportError) as excinfo: - _bare_sdk()._check_dependencies() + monkeypatch.setattr(sdk, "SentenceTransformer", None) + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "sentence_transformers": + raise RuntimeError("Could not load libtorchcodec (FFmpeg not found)") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ImportError) as excinfo: + _bare_sdk()._check_dependencies() msg = str(excinfo.value) assert "installed but failed to load" in msg assert "libtorchcodec" in msg # the captured cause is named -def test_genuinely_missing_dep_omits_broken_section(): +def test_genuinely_missing_dep_omits_broken_section(monkeypatch): """A simply-missing dep gets install instructions, not the broken-load hint.""" - with ( - patch.object(sdk, "SentenceTransformer", None), - patch.object(sdk, "_SENTENCE_TRANSFORMERS_IMPORT_ERROR", None), - ): - with pytest.raises(ImportError) as excinfo: - _bare_sdk()._check_dependencies() + monkeypatch.setattr(sdk, "SentenceTransformer", None) + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "sentence_transformers": + raise ImportError("No module named 'sentence_transformers'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(ImportError) as excinfo: + _bare_sdk()._check_dependencies() msg = str(excinfo.value) assert "sentence-transformers" in msg assert "installed but failed to load" not in msg From a7fd6a6142c4ce691e758e794f3a8315d5f9d937 Mon Sep 17 00:00:00 2001 From: Ovtcharov Date: Mon, 8 Jun 2026 14:19:46 -0700 Subject: [PATCH 3/3] fix(rag): re-trigger import via __import__ so broken-dep cause is recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit importlib.import_module bypasses builtins.__import__, so the recovery path that names an installed-but-broken dependency could never be exercised by the dependency-guard tests (they intercept imports), and in a deps-absent CI environment the broken-cause section was never produced — failing test_broken_install_reports_actionable_cause. Use the __import__ builtin to re-run the real import, which both surfaces the native load error in production and is interceptable in tests. --- src/gaia/rag/sdk.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index a1b404147..5d496366e 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -9,7 +9,6 @@ import errno import hashlib import hmac -import importlib import json import os import re @@ -245,7 +244,12 @@ def _check_dependencies(self): pkg == "faiss" and faiss is None ): try: - importlib.import_module(pkg) + # Use the import statement (__import__), not + # importlib.import_module — the latter bypasses + # builtins.__import__, so this path can't be exercised + # by tests that intercept imports, and re-running the + # real import is what re-surfaces the native cause. + __import__(pkg) except ImportError: pass # genuinely missing → covered by install instructions except Exception as exc: # pylint: disable=broad-except