diff --git a/adapters/README.md b/adapters/README.md index 3a56d727..1487586e 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -13,6 +13,25 @@ installation, authentication, and configuration details. The adapter descriptor selected in `RunPlan` is authoritative for normalized configuration and telemetry support. +## Descriptor Discovery + +As a stopgap until NeMo Fabric has a provider-backed adapter registry, the +Python SDK discovers descriptors in three locations. Later locations take +precedence: + +1. descriptors bundled in the NeMo Fabric source repository; +2. `/share/nemo-fabric/adapters`, populated by adapter wheels + and queried from `ADAPTER_PYTHON` when set, otherwise from the current Python; +3. `/adapters`, for agent-local and development overrides. + +Fabric resolves multi-component relative `ADAPTER_PYTHON` paths from +``. It resolves bare command names through `PATH`. + +This scan only discovers installed metadata. It is not the final registry +contract for resolving or installing third-party adapters. Installed and +agent-local descriptors both currently report `source: local`; a registry +provider should expose more precise provenance. + ## Bundled Adapter Packages | Agent Harness | Adapter ID | Python Package | Supported Python | diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 5316c433..eb19de95 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -164,9 +164,16 @@ struct AdapterRegistry { } impl AdapterRegistry { - fn from_config(_config: &FabricConfig, base_dir: &Path) -> Result { + fn from_config( + _config: &FabricConfig, + base_dir: &Path, + additional_directories: &[PathBuf], + ) -> Result { let mut registry = Self::default(); registry.register_repository_directory(&repository_adapter_dir())?; + for directory in additional_directories { + registry.register_local_directory(directory)?; + } registry.register_local_directory(&base_dir.join("adapters"))?; Ok(registry) } @@ -989,6 +996,20 @@ fn validate_config(config: &FabricConfig) -> Result<()> { pub fn resolve_run_plan_from_config( config: FabricConfig, context: ResolveContext, +) -> Result { + resolve_run_plan_from_config_with_adapter_directories(config, context, &[]) +} + +/// Resolve a typed Fabric config with additional adapter descriptor directories. +/// +/// This is an internal integration surface for hosts that know about +/// environment-specific package data directories. Callers should otherwise use +/// [`resolve_run_plan_from_config`]. +#[doc(hidden)] +pub fn resolve_run_plan_from_config_with_adapter_directories( + config: FabricConfig, + context: ResolveContext, + adapter_directories: &[PathBuf], ) -> Result { validate_config(&config)?; let supplied_base_dir = context.base_dir; @@ -998,7 +1019,7 @@ pub fn resolve_run_plan_from_config( path: supplied_base_dir, source, })?; - resolve_run_plan(config, base_dir) + resolve_run_plan(config, base_dir, adapter_directories) } fn read_json(path: &Path) -> Result @@ -1015,8 +1036,12 @@ where }) } -fn resolve_run_plan(config: FabricConfig, base_dir: PathBuf) -> Result { - let adapter_descriptor = resolve_adapter_descriptor(&config, &base_dir)?; +fn resolve_run_plan( + config: FabricConfig, + base_dir: PathBuf, + adapter_directories: &[PathBuf], +) -> Result { + let adapter_descriptor = resolve_adapter_descriptor(&config, &base_dir, adapter_directories)?; let descriptor = adapter_descriptor .as_ref() .map(|adapter| &adapter.descriptor); @@ -1042,9 +1067,10 @@ fn resolve_run_plan(config: FabricConfig, base_dir: PathBuf) -> Result fn resolve_adapter_descriptor( config: &FabricConfig, base_dir: &Path, + adapter_directories: &[PathBuf], ) -> Result> { let adapter_id = &config.harness.adapter_id; - let registry = AdapterRegistry::from_config(config, base_dir)?; + let registry = AdapterRegistry::from_config(config, base_dir, adapter_directories)?; let Some(entry) = registry.get(adapter_id) else { return Err(FabricError::UnknownAdapter { adapter_id: adapter_id.clone(), @@ -1712,4 +1738,85 @@ mod tests { assert_eq!(descriptor.contract_version, ADAPTER_CONTRACT_VERSION); assert_eq!(descriptor.adapter_kind, AdapterKind::Python); } + + #[test] + fn resolves_additional_adapter_directory_before_agent_local_override() { + struct RemoveDirOnDrop(PathBuf); + + impl Drop for RemoveDirOnDrop { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + let root = std::env::temp_dir().join(format!( + "nemo-fabric-adapter-discovery-{}", + std::process::id() + )); + let _cleanup = RemoveDirOnDrop(root.clone()); + let installed_directory = root.join("installed"); + let base_dir = root.join("agent"); + let installed_descriptor = installed_directory.join("stopgap/fabric-adapter.json"); + let local_descriptor = base_dir.join("adapters/stopgap/fabric-adapter.json"); + let descriptor = |module: &str| { + serde_json::json!({ + "contract_version": ADAPTER_CONTRACT_VERSION, + "adapter_id": "test.fabric.installed", + "harness": "installed-test", + "adapter_kind": "python", + "runner": {"module": module}, + "config": {"accepts": ["skills"]} + }) + }; + + std::fs::create_dir_all(installed_descriptor.parent().expect("installed parent")) + .expect("create installed adapter directory"); + std::fs::write( + &installed_descriptor, + serde_json::to_vec_pretty(&descriptor("installed.adapter")).expect("descriptor JSON"), + ) + .expect("write installed descriptor"); + + let plan = resolve_run_plan_from_config_with_adapter_directories( + typed_config("test.fabric.installed"), + ResolveContext::new(&base_dir), + std::slice::from_ref(&installed_directory), + ) + .expect("installed adapter plan"); + let expected_installed_descriptor = installed_descriptor + .canonicalize() + .expect("canonical installed descriptor"); + assert_eq!( + plan.adapter_descriptor + .as_ref() + .map(|adapter| adapter.path.as_path()), + Some(expected_installed_descriptor.as_path()) + ); + + std::fs::create_dir_all(local_descriptor.parent().expect("local parent")) + .expect("create local adapter directory"); + std::fs::write( + &local_descriptor, + serde_json::to_vec_pretty(&descriptor("local.adapter")).expect("descriptor JSON"), + ) + .expect("write local descriptor"); + + let plan = resolve_run_plan_from_config_with_adapter_directories( + typed_config("test.fabric.installed"), + ResolveContext::new(&base_dir), + &[installed_directory], + ) + .expect("agent-local adapter plan"); + let resolved = plan.adapter_descriptor.expect("resolved adapter"); + assert_eq!( + resolved.path, + local_descriptor + .canonicalize() + .expect("canonical local descriptor") + ); + assert_eq!( + resolved.descriptor.runner["module"], + serde_json::json!("local.adapter") + ); + } } diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 2d55cc98..c2fc71dc 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -17,7 +17,7 @@ pub use config::{ ModelConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, load_adapter_descriptor, - resolve_run_plan_from_config, + resolve_run_plan_from_config, resolve_run_plan_from_config_with_adapter_directories, }; pub use doctor::{DoctorCheck, DoctorReport, DoctorStatus, doctor_plan}; pub use error::{FabricError, Result}; diff --git a/crates/fabric-python/src/lib.rs b/crates/fabric-python/src/lib.rs index 1362f480..01142e93 100644 --- a/crates/fabric-python/src/lib.rs +++ b/crates/fabric-python/src/lib.rs @@ -3,15 +3,33 @@ //! Python native bindings for NeMo Fabric. -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; use nemo_fabric_core::{ FabricConfig, ResolveContext, RunPlan, RunRequest, RuntimeHandle, doctor_plan, - resolve_run_plan_from_config, run_plan, + resolve_run_plan_from_config_with_adapter_directories, run_plan, }; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +const ADAPTER_PYTHON_ENV: &str = "ADAPTER_PYTHON"; +const PYTHON_DATA_PATH_QUERY_TIMEOUT: Duration = Duration::from_secs(5); +const PYTHON_DATA_PATH_QUERY_POLL_INTERVAL: Duration = Duration::from_millis(10); +const PYTHON_DATA_PATH_SCRIPT: &str = + "import json, sysconfig; print(json.dumps(sysconfig.get_path('data')))"; + +#[derive(serde::Deserialize)] +struct PythonDiscoverySettings { + #[serde(default)] + python: Option, + #[serde(default)] + python_env: Option, +} + /// Return the Fabric core version. #[pyfunction] fn version() -> PyResult { @@ -23,8 +41,15 @@ fn version() -> PyResult { #[pyo3(signature = (config_json, base_dir=None))] fn plan_config(py: Python<'_>, config_json: String, base_dir: Option) -> PyResult { let config = parse_config(config_json)?; + let (context, adapter_directories) = resolve_context(py, base_dir, &config)?; let plan = py - .detach(|| resolve_run_plan_from_config(config, resolve_context(base_dir))) + .detach(|| { + resolve_run_plan_from_config_with_adapter_directories( + config, + context, + &adapter_directories, + ) + }) .map_err(to_py_error)?; to_json(&plan) } @@ -38,8 +63,15 @@ fn doctor_config( base_dir: Option, ) -> PyResult { let config = parse_config(config_json)?; + let (context, adapter_directories) = resolve_context(py, base_dir, &config)?; let plan = py - .detach(|| resolve_run_plan_from_config(config, resolve_context(base_dir))) + .detach(|| { + resolve_run_plan_from_config_with_adapter_directories( + config, + context, + &adapter_directories, + ) + }) .map_err(to_py_error)?; to_json(&doctor_plan(&plan)) } @@ -57,8 +89,15 @@ fn run_config( request_file: Option, ) -> PyResult { let config = parse_config(config_json)?; + let (context, adapter_directories) = resolve_context(py, base_dir, &config)?; let plan = py - .detach(|| resolve_run_plan_from_config(config, resolve_context(base_dir))) + .detach(|| { + resolve_run_plan_from_config_with_adapter_directories( + config, + context, + &adapter_directories, + ) + }) .map_err(to_py_error)?; let request = match (request_file, request_json, input_file, input_text) { (Some(path), None, None, None) => std::fs::read_to_string(PathBuf::from(&path)) @@ -146,8 +185,136 @@ fn to_py_error(error: nemo_fabric_core::FabricError) -> PyErr { PyRuntimeError::new_err(error.to_string()) } -fn resolve_context(base_dir: Option) -> ResolveContext { - ResolveContext::new(base_dir.unwrap_or_else(|| ".".to_string())) +fn resolve_context( + py: Python<'_>, + base_dir: Option, + config: &FabricConfig, +) -> PyResult<(ResolveContext, Vec)> { + let base_dir = PathBuf::from(base_dir.unwrap_or_else(|| ".".to_string())); + let data_path = match discovery_python(config, &base_dir)? { + Some((python, origin)) => py + .detach(|| query_python_data_path(&python, &origin)) + .map_err(PyRuntimeError::new_err)?, + _ => py + .import("sysconfig")? + .call_method1("get_path", ("data",))? + .extract()?, + }; + // Stopgap: Python adapter wheels install descriptors under the interpreter's + // data root. Use the runtime's explicit interpreter precedence so descriptor + // metadata matches the adapter code that will execute. A provider-backed + // adapter registry should replace this implicit environment scan. + let installed_adapters = PathBuf::from(data_path) + .join("share") + .join("nemo-fabric") + .join("adapters"); + Ok((ResolveContext::new(base_dir), vec![installed_adapters])) +} + +fn discovery_python(config: &FabricConfig, base_dir: &Path) -> PyResult> { + let settings: PythonDiscoverySettings = + serde_json::from_value(serde_json::Value::Object(config.harness.settings.clone())) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + if let Some(python) = settings.python { + return Ok(Some(( + resolve_adapter_python(base_dir, python.into_os_string()), + "harness.settings.python".to_string(), + ))); + } + if let Some(env_name) = settings.python_env { + return Ok(std::env::var_os(&env_name) + .filter(|python| !python.is_empty()) + .map(|python| { + ( + resolve_adapter_python(base_dir, python), + format!("harness.settings.python_env (`{env_name}`)"), + ) + })); + } + Ok(std::env::var_os(ADAPTER_PYTHON_ENV) + .filter(|python| !python.is_empty()) + .map(|python| { + ( + resolve_adapter_python(base_dir, python), + ADAPTER_PYTHON_ENV.to_string(), + ) + })) +} + +fn resolve_adapter_python(base_dir: &Path, adapter_python: OsString) -> PathBuf { + let adapter_python = PathBuf::from(adapter_python); + if adapter_python.is_absolute() || adapter_python.components().count() == 1 { + adapter_python + } else { + base_dir.join(adapter_python) + } +} + +fn query_python_data_path(python: &Path, origin: &str) -> Result { + let mut child = Command::new(python) + .arg("-c") + .arg(PYTHON_DATA_PATH_SCRIPT) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "failed to query {origin} interpreter `{}` for its data path: {error}", + python.display() + ) + })?; + let deadline = Instant::now() + PYTHON_DATA_PATH_QUERY_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + thread::sleep(PYTHON_DATA_PATH_QUERY_POLL_INTERVAL); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{origin} interpreter `{}` timed out after {} seconds while reporting its data path", + python.display(), + PYTHON_DATA_PATH_QUERY_TIMEOUT.as_secs() + )); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "failed to wait for {origin} interpreter `{}` while querying its data path: {error}", + python.display() + )); + } + } + } + let output = child.wait_with_output().map_err(|error| { + format!( + "failed to collect {origin} interpreter `{}` data path output: {error}", + python.display() + ) + })?; + if !output.status.success() { + return Err(format!( + "{origin} interpreter `{}` could not report its data path: {}", + python.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let data_path: String = serde_json::from_slice(&output.stdout).map_err(|error| { + format!( + "{origin} interpreter `{}` returned an invalid data path: {error}", + python.display() + ) + })?; + if data_path.is_empty() { + return Err(format!( + "{origin} interpreter `{}` returned an empty data path", + python.display() + )); + } + Ok(data_path) } fn parse_config(contents: String) -> PyResult { diff --git a/tests/python/test_installed_adapter_discovery.py b/tests/python/test_installed_adapter_discovery.py new file mode 100644 index 00000000..61771b90 --- /dev/null +++ b/tests/python/test_installed_adapter_discovery.py @@ -0,0 +1,250 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Coverage for the installed adapter descriptor discovery stopgap.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sysconfig +import time +import venv +from collections.abc import Callable +from pathlib import Path + +import pytest +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig +from nemo_fabric import FabricConfigError + + +def _write_descriptor( + root: Path, + module: str, + adapter_id: str = "test.fabric.installed", +) -> Path: + descriptor = root / "share/nemo-fabric/adapters/test/fabric-adapter.json" + descriptor.parent.mkdir(parents=True) + descriptor.write_text( + json.dumps( + { + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": adapter_id, + "harness": "installed-test", + "adapter_kind": "python", + "runner": {"module": module}, + } + ) + ) + return descriptor + + +def _config( + adapter_id: str = "test.fabric.installed", + settings: dict[str, str] | None = None, +) -> FabricConfig: + return FabricConfig.from_mapping( + { + "metadata": {"name": "installed-adapter-test"}, + "harness": { + "adapter_id": adapter_id, + "settings": settings or {}, + }, + } + ) + + +@pytest.fixture(name="_clear_adapter_python", autouse=True) +def _clear_adapter_python_fixture() -> None: + os.environ.pop("ADAPTER_PYTHON", None) + + +@pytest.fixture(name="patch_sysconfig_data") +def patch_sysconfig_data_fixture( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[Path], None]: + original_get_path = sysconfig.get_path + + def patch(data_root: Path) -> None: + def get_path( + name: str, + *args: object, + **kwargs: object, + ) -> str | None: + if name == "data": + return str(data_root) + return original_get_path(name, *args, **kwargs) + + monkeypatch.setattr(sysconfig, "get_path", get_path) + + return patch + + +def _python_sysconfig_path(python: Path, name: str) -> Path: + return Path( + subprocess.check_output( + [ + python, + "-c", + f"import sysconfig; print(sysconfig.get_path({name!r}), end='')", + ], + text=True, + ) + ) + + +@pytest.fixture(name="adapter_python") +def adapter_python_fixture(tmp_path: Path) -> tuple[Path, Path]: + adapter_env = tmp_path / "adapter-env" + venv.EnvBuilder(with_pip=False).create(adapter_env) + python = adapter_env / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + data_root = _python_sysconfig_path(python, "data") + descriptor = _write_descriptor(data_root, "configured.adapter") + return python, descriptor + + +def test_plan_discovers_adapter_from_python_data_directory( + tmp_path: Path, + patch_sysconfig_data: Callable[[Path], None], +): + data_root = tmp_path / "python-data" + descriptor = _write_descriptor(data_root, "installed.adapter") + patch_sysconfig_data(data_root) + + plan = Fabric().plan(_config(), base_dir=tmp_path / "agent") + + assert Path(plan["adapter_descriptor"]["path"]).samefile(descriptor) + assert plan["adapter_descriptor"]["source"] == "local" + + +def test_agent_local_descriptor_overrides_installed_descriptor( + tmp_path: Path, + patch_sysconfig_data: Callable[[Path], None], +): + data_root = tmp_path / "python-data" + _write_descriptor(data_root, "installed.adapter") + base_dir = tmp_path / "agent" + local_descriptor = base_dir / "adapters/test/fabric-adapter.json" + local_descriptor.parent.mkdir(parents=True) + local_descriptor.write_text( + json.dumps( + { + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "test.fabric.installed", + "harness": "installed-test", + "adapter_kind": "python", + "runner": {"module": "local.adapter"}, + } + ) + ) + patch_sysconfig_data(data_root) + + plan = Fabric().plan(_config(), base_dir=base_dir) + + assert Path(plan["adapter_descriptor"]["path"]).samefile(local_descriptor) + assert ( + plan["adapter_descriptor"]["descriptor"]["runner"]["module"] == "local.adapter" + ) + + +def test_adapter_python_data_directory_replaces_current_data_directory( + tmp_path: Path, + patch_sysconfig_data: Callable[[Path], None], +): + current_data_root = tmp_path / "current-python-data" + _write_descriptor( + current_data_root, + "current.adapter", + adapter_id="test.fabric.current-only", + ) + patch_sysconfig_data(current_data_root) + + adapter_env = tmp_path / "adapter-env" + venv.EnvBuilder(with_pip=False).create(adapter_env) + adapter_python = adapter_env / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + adapter_data_root = _python_sysconfig_path(adapter_python, "data") + adapter_descriptor = _write_descriptor(adapter_data_root, "adapter.environment") + os.environ["ADAPTER_PYTHON"] = str(adapter_python) + + plan = Fabric().plan(_config(), base_dir=tmp_path / "agent") + + assert Path(plan["adapter_descriptor"]["path"]).samefile(adapter_descriptor) + assert ( + plan["adapter_descriptor"]["descriptor"]["runner"]["module"] + == "adapter.environment" + ) + with pytest.raises(FabricConfigError, match="unknown adapter"): + Fabric().plan( + _config("test.fabric.current-only"), + base_dir=tmp_path / "agent", + ) + + +def test_harness_python_takes_precedence_over_adapter_python( + tmp_path: Path, + adapter_python: tuple[Path, Path], +): + python, descriptor = adapter_python + os.environ["ADAPTER_PYTHON"] = str(tmp_path / "missing-python") + + plan = Fabric().plan( + _config(settings={"python": str(python)}), + base_dir=tmp_path / "agent", + ) + + assert Path(plan["adapter_descriptor"]["path"]).samefile(descriptor) + + +def test_harness_python_env_takes_precedence_over_adapter_python( + tmp_path: Path, + adapter_python: tuple[Path, Path], +): + python, descriptor = adapter_python + os.environ["TEST_ADAPTER_PYTHON"] = str(python) + os.environ["ADAPTER_PYTHON"] = str(tmp_path / "missing-python") + + plan = Fabric().plan( + _config(settings={"python_env": "TEST_ADAPTER_PYTHON"}), + base_dir=tmp_path / "agent", + ) + + assert Path(plan["adapter_descriptor"]["path"]).samefile(descriptor) + + +def test_unset_harness_python_env_uses_sdk_interpreter( + tmp_path: Path, + patch_sysconfig_data: Callable[[Path], None], +): + data_root = tmp_path / "python-data" + descriptor = _write_descriptor(data_root, "sdk.adapter") + patch_sysconfig_data(data_root) + os.environ.pop("MISSING_ADAPTER_PYTHON", None) + os.environ["ADAPTER_PYTHON"] = str(tmp_path / "missing-python") + + plan = Fabric().plan( + _config(settings={"python_env": "MISSING_ADAPTER_PYTHON"}), + base_dir=tmp_path / "agent", + ) + + assert Path(plan["adapter_descriptor"]["path"]).samefile(descriptor) + + +def test_adapter_python_data_path_query_times_out(tmp_path: Path): + adapter_env = tmp_path / "slow-adapter-env" + venv.EnvBuilder(with_pip=False).create(adapter_env) + adapter_python = adapter_env / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + purelib = _python_sysconfig_path(adapter_python, "purelib") + (purelib / "slow_startup.pth").write_text("import time; time.sleep(30)\n") + os.environ["ADAPTER_PYTHON"] = str(adapter_python) + + started = time.monotonic() + with pytest.raises(FabricConfigError, match="timed out after 5 seconds"): + Fabric().plan(_config(), base_dir=tmp_path / "agent") + + assert time.monotonic() - started < 10 diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index cda9e6be..dc4ba025 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -17,7 +17,7 @@ from examples.code_review_agent import base_config from nemo_fabric import Fabric from nemo_fabric import FabricConfig -from nemo_fabric import FabricRuntimeError +from nemo_fabric import FabricConfigError async def test_native_sdk(hermes_shim_agent_dir: Path): @@ -40,20 +40,18 @@ async def test_adapter_python_selects_python_adapter_interpreter( assert result["status"] == "succeeded" -async def test_adapter_python_rejects_invalid_path_before_start( +def test_adapter_python_rejects_invalid_path_during_plan( hermes_shim_agent_dir: Path, ): invalid_python = hermes_shim_agent_dir / "missing-python" os.environ["ADAPTER_PYTHON"] = str(invalid_python) - with pytest.raises(FabricRuntimeError, match="ADAPTER_PYTHON") as caught: - await Fabric().start_runtime( + with pytest.raises(FabricConfigError, match="ADAPTER_PYTHON"): + Fabric().plan( hermes_shim_config(), base_dir=hermes_shim_agent_dir, ) - assert caught.value.stage == "start" - def test_native_run_rejects_multiple_request_sources(hermes_shim_agent_dir: Path): with pytest.raises(RuntimeError, match="mutually exclusive"):