"""Context provider registry and loader.
Context providers inject dynamic runtime data into the LLM context on every call.
Each provider is a .py module with:
- name: str
- description: str
- global_provider: bool (True = injected in all profiles regardless of config)
- async def get_context() -> str | None
"""
import importlib.util
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import structlog
log = structlog.get_logger()
_REQUIRED = ("name", "description", "get_context")
_BUILTIN_PREFIX = "navi_cp"
_USER_PREFIX = "user_cp"
@dataclass
class CPLoadResult:
loaded: list[str] = field(default_factory=list)
errors: dict[str, str] = field(default_factory=dict)
class ContextProviderRegistry:
def __init__(self) -> None:
self._providers: dict[str, Any] = {}
self._builtin_names: set[str] = set()
def register(self, module: Any, builtin: bool = False) -> None:
self._providers[module.name] = module
if builtin:
self._builtin_names.add(module.name)
def get(self, name: str) -> Any | None:
return self._providers.get(name)
def all_names(self) -> list[str]:
return list(self._providers)
def get_globals(self) -> list[Any]:
return [p for p in self._providers.values() if getattr(p, "global_provider", False)]
def get_named(self, names: list[str]) -> list[Any]:
return [self._providers[n] for n in names if n in self._providers]
def load_from_dir(self, path: str, builtin: bool = False) -> CPLoadResult:
result = CPLoadResult()
dir_path = Path(path)
if not dir_path.is_dir():
return result
prefix = _BUILTIN_PREFIX if builtin else _USER_PREFIX
for py_file in sorted(dir_path.glob("*.py")):
if py_file.stem.startswith("_"):
continue
mod_key = f"{prefix}.{py_file.stem}"
try:
spec = importlib.util.spec_from_file_location(mod_key, py_file)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot create spec for {py_file.name}")
mod = importlib.util.module_from_spec(spec)
sys.modules[mod_key] = mod
spec.loader.exec_module(mod) # type: ignore[union-attr]
missing = [r for r in _REQUIRED if not hasattr(mod, r)]
if missing:
raise AttributeError(f"Missing required definitions: {', '.join(missing)}")
if not callable(getattr(mod, "get_context", None)):
raise AttributeError("get_context must be callable")
self.register(mod, builtin=builtin)
result.loaded.append(mod.name)
log.info("context_provider.loaded", name=mod.name, file=py_file.name)
except Exception as exc:
result.errors[py_file.name] = str(exc)
sys.modules.pop(mod_key, None)
log.warning("context_provider.load_error", file=py_file.name, error=str(exc))
return result
def reload_user_providers(self, path: str) -> CPLoadResult:
for name in [n for n in self._providers if n not in self._builtin_names]:
del self._providers[name]
for key in [k for k in sys.modules if k.startswith(f"{_USER_PREFIX}.")]:
del sys.modules[key]
return self.load_from_dir(path, builtin=False)