From a62d4274e8c53527856616e1675182252e77d0df Mon Sep 17 00:00:00 2001 From: Zhongxuan Wang Date: Mon, 10 Aug 2026 20:00:59 -0700 Subject: [PATCH] feat: move Deep Agents adapter to v1alpha2 Signed-off-by: Zhongxuan Wang --- .../src/nemo_fabric_adapters/common/utils.py | 10 +- adapters/deepagents/fabric-adapter.json | 1 + adapters/deepagents/pyproject.toml | 2 + .../deepagents/adapter.py | 169 ++++++++------ adapters/deepagents/uv.lock | 11 + .../adapters/deepagents/fabric-adapter.json | 1 + crates/fabric-core/src/runtime.rs | 25 ++- tests/adapters/test_adapaters_common_utils.py | 6 +- .../adapters/test_adapter_package_metadata.py | 1 + tests/adapters/test_deepagents.py | 207 +++++++++++------- uv.lock | 2 + 11 files changed, 276 insertions(+), 159 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 96ae9c72..42efaec4 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -231,7 +231,9 @@ def dump_yaml(value: dict[str, Any]) -> str: return json.dumps(value, indent=2, sort_keys=False) + "\n" -def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: +def load_relay_plugin_config( + payload: dict[str, Any], *, model_name: str | None = None +) -> dict[str, Any]: config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") if not config_path: raise RuntimeError("FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled") @@ -254,12 +256,12 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: } plugin_config.setdefault("version", 1) plugin_config.setdefault("components", []) - normalize_relay_output_dirs(plugin_config, payload) + normalize_relay_output_dirs(plugin_config, payload, model_name=model_name) return plugin_config def normalize_relay_output_dirs( - plugin_config: dict[str, Any], payload: dict[str, Any] + plugin_config: dict[str, Any], payload: dict[str, Any], *, model_name: str | None = None ) -> None: base = Path(base_dir(payload)).resolve() runtime_id = runtime_context(payload)["runtime_id"] @@ -301,7 +303,7 @@ def normalize_relay_output_dirs( Path(atif["output_directory"]).mkdir(parents=True, exist_ok=True) atif.setdefault("filename_template", "trajectory-{session_id}.atif.json") atif.setdefault("agent_name", agent_name(payload)) - atif.setdefault("model_name", relay_model_name(payload)) + atif.setdefault("model_name", model_name or relay_model_name(payload)) def _artifact_directory(value: Any) -> Path | None: diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index 86366f69..59ae48c1 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -164,6 +164,7 @@ }, "requirements": {}, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/adapters/deepagents/pyproject.toml b/adapters/deepagents/pyproject.toml index 70de4a1b..cd4979ee 100644 --- a/adapters/deepagents/pyproject.toml +++ b/adapters/deepagents/pyproject.toml @@ -25,6 +25,7 @@ license-files = ["LICENSE"] readme = "pypi.md" requires-python = ">=3.11" dependencies = [ + "nemo-fabric-adapter-contract == 0.2.0", "nemo-fabric-adapters-common == 0.2.0", "langchain-mcp-adapters>=0.1,<0.3.0", "langchain-openai>=0.3", @@ -63,4 +64,5 @@ include = ["nemo_fabric_adapters.deepagents*"] "share/nemo-fabric/adapters/deepagents" = ["fabric-adapter.json"] [tool.uv.sources] +nemo-fabric-adapter-contract = { path = "../../adapter-contract", editable = true } nemo-fabric-adapters-common = { path = "../common", editable = true } diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 8b16617b..8747011b 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -23,6 +23,10 @@ from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ToolMessage +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import AgentMcpServerConfig +from nemo_fabric_adapter_contract.models import AgentModelConfig +from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.common import lifecycle import nemo_fabric_adapters.common.utils as common_utils @@ -86,21 +90,21 @@ def wrap_tool_call(self, request: Any, handler: Any) -> Any: return handler(request) -def resolve_api_key_env(model_config: dict[str, Any]) -> str: +def resolve_api_key_env(model_config: AgentModelConfig) -> str: """Resolve the credential env var. OpenAI retains its conventional environment variable. Other providers must name the credential explicitly so a key is never sent to the wrong endpoint. """ - explicit = model_config.get("api_key_env") + explicit = model_config.api_key_env if isinstance(explicit, str) and explicit: return explicit if explicit is not None: raise AdapterConfigError( "models.default.api_key_env must be a non-empty string." ) - provider = str(model_config.get("provider") or "").lower() + provider = str(model_config.provider or "").lower() if provider == "openai": return "OPENAI_API_KEY" raise AdapterConfigError( @@ -111,10 +115,10 @@ def resolve_api_key_env(model_config: dict[str, Any]) -> str: def main() -> None: """Serve the persistent local-host lifecycle protocol.""" - lifecycle.serve(DeepAgentsRuntime) + lifecycle.serve(DeepAgentsRuntime, config_loader=AgentConfig.from_mapping) -def preflight_check(payload: dict[str, Any]) -> None: +def preflight_check(model_config: AgentModelConfig) -> None: """Validate invocation-time prerequisites and fail fast with clear errors. These checks run during adapter startup. Core descriptor diagnostics do not @@ -132,7 +136,6 @@ def preflight_check(payload: dict[str, Any]) -> None: "a compatible Deep Agents harness in the adapter environment." ) - model_config = selected_model_config(payload) api_key_env = resolve_api_key_env(model_config) if api_key_env not in os.environ: raise RuntimeError( @@ -142,23 +145,26 @@ def preflight_check(payload: dict[str, Any]) -> None: ) -def selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: - return common_utils.selected_model_config(payload) +def selected_model_config(config: AgentConfig) -> AgentModelConfig: + if model_config := config.models.get("default"): + return model_config + if len(config.models) == 1: + return next(iter(config.models.values())) + raise AdapterConfigError("Deep Agents requires a default model or exactly one model.") -def resolve_base_url(model_config: dict[str, Any]) -> str | None: - return common_utils.get_base_url(model_config) +def resolve_base_url(model_config: AgentModelConfig) -> str | None: + return model_config.base_url -def build_chat_model(payload: dict[str, Any]) -> tuple[Any, str, str | None]: +def build_chat_model(model_config: AgentModelConfig) -> tuple[Any, str, str | None]: """Build a LangChain chat model from Fabric model config. Known OpenAI-compatible providers use ``ChatOpenAI``. Other providers are delegated to ``langchain.chat_models.init_chat_model``. """ - model_config = selected_model_config(payload) - model_name = model_config.get("model") + model_name = model_config.model if not model_name: raise RuntimeError( "models.default.model is required for the Deep Agents adapter" @@ -169,11 +175,11 @@ def build_chat_model(payload: dict[str, Any]) -> tuple[Any, str, str | None]: if not api_key: raise RuntimeError(f"{api_key_env} is required for the Deep Agents adapter") - provider = str(model_config.get("provider") or "").lower() + provider = str(model_config.provider or "").lower() if not provider: raise AdapterConfigError("models.default.provider is required.") base_url = resolve_base_url(model_config) - temperature = model_config.get("temperature") + temperature = model_config.temperature if provider in OPENAI_COMPATIBLE_PROVIDERS - {"openai"} and not base_url: raise AdapterConfigError( @@ -205,16 +211,15 @@ def build_chat_model(payload: dict[str, Any]) -> tuple[Any, str, str | None]: return ChatOpenAI(**_supported_kwargs(ChatOpenAI, kwargs)), model_name, base_url -def resolve_backend(payload: dict[str, Any]) -> Any: +def resolve_backend(runtime_context: RuntimeContext, base_dir: str) -> Any: """Root the Deep Agents filesystem backend at the Fabric workspace, if set.""" - environment = common_utils.environment_payload(payload) - workspace = environment.get("workspace") + workspace = runtime_context.environment.workspace if not workspace: return None root = Path(str(workspace)) if not root.is_absolute(): - root = Path(common_utils.base_dir(payload)) / root + root = Path(base_dir) / root from deepagents.backends import FilesystemBackend # virtual_mode=True confines the agent to root_dir; absolute paths and ``..`` @@ -222,19 +227,19 @@ def resolve_backend(payload: dict[str, Any]) -> Any: return FilesystemBackend(root_dir=str(root), virtual_mode=True) -async def resolve_tools(payload: dict[str, Any]) -> list[Any] | None: +async def resolve_tools(config: AgentConfig) -> list[Any] | None: """Resolve Fabric MCP servers into Deep Agents tools.""" - tools = await _mcp_tools(payload) + tools = await _mcp_tools(config) return tools or None -def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: - return set(common_utils.blocked_tools(payload)) +def _blocked_tool_names(config: AgentConfig) -> set[str]: + return set(config.tools.blocked if config.tools is not None else []) -def _enabled_tool_names(payload: dict[str, Any]) -> set[str] | None: - enabled = common_utils.enabled_tools(payload) +def _enabled_tool_names(config: AgentConfig) -> set[str] | None: + enabled = config.tools.enabled if config.tools is not None else None return None if enabled is None else set(enabled) @@ -253,17 +258,15 @@ def tool_policy_middleware(enabled: set[str] | None, blocked: set[str]) -> Any: ) -def resolve_skills(payload: dict[str, Any]) -> list[str] | None: - """Map routed ``native.skill_paths`` onto the Deep Agents ``skills`` sources.""" +def resolve_skills(config: AgentConfig) -> list[str] | None: + """Map Fabric skill paths onto the Deep Agents ``skills`` sources.""" - native = common_utils.capability_plan(payload).get("native") or {} - skills = [str(path) for path in (native.get("skill_paths") or [])] + skills = [str(path) for path in (config.skills.paths if config.skills else [])] return skills or None -async def _mcp_tools(payload: dict[str, Any]) -> list[Any]: - native = common_utils.capability_plan(payload).get("native") or {} - servers = native.get("mcp_servers") or {} +async def _mcp_tools(config: AgentConfig) -> list[Any]: + servers = config.mcp.servers if config.mcp is not None else {} connections = {name: _mcp_connection(name, spec) for name, spec in servers.items()} if not connections: return [] @@ -273,13 +276,11 @@ async def _mcp_tools(payload: dict[str, Any]) -> list[Any]: return list(await client.get_tools()) -def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: +def _mcp_connection(name: str, spec: AgentMcpServerConfig) -> dict[str, Any]: # A misconfigured server must fail loudly, not be silently dropped. - if not isinstance(spec, dict): - raise AdapterConfigError(f"MCP server '{name}' must be a mapping.") - transport = str(spec.get("transport") or "").strip().lower().replace("-", "_") - # McpServerPlan carries the command in ``url`` and preserves stdio extensions. - target = os.path.expandvars(str(spec.get("url") or "")).strip() + transport = str(spec.transport or "").strip().lower().replace("-", "_") + # AgentMcpServerConfig carries the command in ``url`` and stdio extensions. + target = os.path.expandvars(str(spec.url or "")).strip() if not target: raise AdapterConfigError( f"MCP server '{name}' requires a url (or command in url)." @@ -288,9 +289,9 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: connection: dict[str, Any] = { "transport": "stdio", "command": target, - "args": common_utils.normalize_list(spec.get("args")), + "args": spec.args, } - if env := spec.get("env"): + if env := spec.env: connection["env"] = env return connection if transport in ("", "http", "streamable_http", "streamablehttp"): @@ -305,18 +306,17 @@ def _mcp_connection(name: str, spec: dict[str, Any]) -> dict[str, Any]: # --- runtime state --------------------------------------------------------- -def state_dir(payload: dict[str, Any]) -> Path: - base_dir = Path(common_utils.base_dir(payload)).resolve() - artifacts = common_utils.runtime_context(payload).get("artifacts") or {} - root = artifacts.get("root") or os.environ.get("FABRIC_ARTIFACTS") +def state_dir(runtime_context: RuntimeContext, base_dir: str) -> Path: + base_dir = Path(base_dir).resolve() + root = runtime_context.artifacts.root or os.environ.get("FABRIC_ARTIFACTS") if root: return Path(str(root)).resolve() / ".fabric" / "deepagents" return base_dir / "artifacts" / "deepagents" / ".fabric" -def checkpointer_path(payload: dict[str, Any], runtime_id: str) -> Path: +def checkpointer_path(context: RuntimeContext, base_dir: str, runtime_id: str) -> Path: key = hashlib.sha256(runtime_id.encode("utf-8")).hexdigest() - base = state_dir(payload) / "runtimes" + base = state_dir(context, base_dir) / "runtimes" return base / f"{key}.sqlite" @@ -347,23 +347,30 @@ async def close_checkpointer(checkpointer: Any) -> None: async def build_agent_kwargs( - payload: dict[str, Any], model: Any, settings: dict[str, Any] + config: AgentConfig, + runtime_context: RuntimeContext, + base_dir: str, + model: Any, + settings: dict[str, Any], ) -> dict[str, Any]: + instructions = config.instructions kwargs: dict[str, Any] = { "model": model, - "tools": await resolve_tools(payload), + "tools": await resolve_tools(config), # deepagents 0.5.x/0.6.x take the system prompt as ``system_prompt``. - "system_prompt": common_utils.system_instruction(payload), - "skills": resolve_skills(payload), - "backend": resolve_backend(payload), + "system_prompt": ( + instructions.system.content if instructions and instructions.system else None + ), + "skills": resolve_skills(config), + "backend": resolve_backend(runtime_context, base_dir), } # Deep Agents-specific settings (e.g. subagents, interrupt_on) pass through, # after validation against the documented JSON-serializable allow-list. extra = settings.get("deepagents") if extra is not None: kwargs.update(_validated_passthrough(extra)) - enabled = _enabled_tool_names(payload) - blocked = _blocked_tool_names(payload) + enabled = _enabled_tool_names(config) + blocked = _blocked_tool_names(config) if enabled is not None or blocked: middleware = list(kwargs.get("middleware") or []) middleware.append(tool_policy_middleware(enabled, blocked)) @@ -480,15 +487,27 @@ async def start(self, payload: dict[str, Any]) -> None: ) try: - preflight_check(payload) - settings = common_utils.settings_payload(payload) - runtime_id = common_utils.runtime_context(payload).get("runtime_id") - model, self._model_name, self._base_url = build_chat_model(payload) + agent_config = payload.get("config") + if not isinstance(agent_config, AgentConfig): + raise lifecycle.LifecycleError( + "deepagents_invalid_config", + "Deep Agents requires a validated AgentConfig", + ) + runtime_context = RuntimeContext.from_mapping(payload.get("runtime_context")) + model_config = selected_model_config(agent_config) + preflight_check(model_config) + settings = agent_config.harness.settings if agent_config.harness else {} + base_dir = common_utils.base_dir(payload) + runtime_id = runtime_context.runtime_id + model, self._model_name, self._base_url = build_chat_model(model_config) self._runtime_id = runtime_id self._thread_id = uuid.uuid4().hex if runtime_id else None - telemetry_providers = common_utils.telemetry_providers(payload) - relay_enabled = common_utils.relay_enabled(payload) + telemetry = runtime_context.telemetry + telemetry_providers = ( + telemetry.metadata.get("telemetry_providers", []) if telemetry else [] + ) + relay_enabled = bool(telemetry and telemetry.relay_enabled) self._telemetry_provider = ( "relay" if relay_enabled @@ -498,14 +517,22 @@ async def start(self, payload: dict[str, Any]) -> None: ) self._observability = resolve_observability( payload, + runtime_context, + model_config.model, self._telemetry_provider, relay_enabled, ) - agent_kwargs = await build_agent_kwargs(payload, model, settings) + agent_kwargs = await build_agent_kwargs( + agent_config, + runtime_context, + base_dir, + model, + settings, + ) if runtime_id: self._checkpointer = await open_checkpointer( - checkpointer_path(payload, runtime_id) + checkpointer_path(runtime_context, base_dir, runtime_id) ) agent_kwargs["checkpointer"] = self._checkpointer @@ -552,7 +579,8 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: "deepagents_runtime_not_started", "Deep Agents runtime is not started", ) - runtime_id = common_utils.runtime_context(invocation).get("runtime_id") + runtime_context = RuntimeContext.from_mapping(invocation.get("runtime_context")) + runtime_id = runtime_context.runtime_id if runtime_id != self._runtime_id: raise lifecycle.LifecycleError( "deepagents_runtime_mismatch", @@ -568,7 +596,7 @@ async def invoke(self, invocation: dict[str, Any]) -> dict[str, Any]: user_message = request.get("input") or "" if not isinstance(user_message, str): user_message = json.dumps(user_message, sort_keys=True) - request_id = request.get("request_id") + request_id = runtime_context.request_id result_state: Any = None events: list[dict[str, Any]] = [] @@ -744,26 +772,31 @@ def _relay_dependency_error() -> RuntimeError: def resolve_observability( - payload: dict[str, Any], telemetry_provider: str, relay_enabled: bool + payload: dict[str, Any], + runtime_context: RuntimeContext, + model_name: str, + telemetry_provider: str, + relay_enabled: bool, ) -> Observability | None: """Resolve the nemo_relay observability plugin config for relay or native telemetry. Relay telemetry loads its plugin config from ``FABRIC_RELAY_CONFIG_PATH`` and collects ATOF/ATIF artifacts. Native telemetry reads - ``telemetry_plan.native_config`` from the payload (e.g. an + ``RuntimeContext.telemetry.metadata.native_config`` (e.g. an OpenTelemetry/OpenInference exporter) and exports spans directly to the configured collector without writing relay artifacts. """ if relay_enabled and telemetry_provider == "relay": return Observability( - common_utils.load_relay_plugin_config(payload), + common_utils.load_relay_plugin_config(payload, model_name=model_name), "deepagents.observability/nemo_relay", True, ) if telemetry_provider == "native": - native_config = common_utils.native_telemetry_config(payload) - if native_config.get("components"): + telemetry = runtime_context.telemetry + native_config = telemetry.metadata.get("native_config", {}) if telemetry else {} + if isinstance(native_config, dict) and native_config.get("components"): return Observability( native_config, "deepagents.observability/native", False ) diff --git a/adapters/deepagents/uv.lock b/adapters/deepagents/uv.lock index bc1eef8e..8e1120cd 100644 --- a/adapters/deepagents/uv.lock +++ b/adapters/deepagents/uv.lock @@ -832,6 +832,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] +[[package]] +name = "nemo-fabric-adapter-contract" +version = "0.2.0" +source = { editable = "../../adapter-contract" } + +[package.metadata] +requires-dist = [{ name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.12,<3" }] +provides-extras = ["pydantic"] + [[package]] name = "nemo-fabric-adapters-common" version = "0.2.0" @@ -845,6 +854,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph-checkpoint-sqlite" }, + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, ] @@ -875,6 +885,7 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'full'", specifier = ">=1.2,<2.0" }, { name = "langgraph", marker = "extra == 'harness'", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, + { name = "nemo-fabric-adapter-contract", editable = "../../adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" }, diff --git a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json index 86366f69..59ae48c1 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/fabric-adapter.json @@ -164,6 +164,7 @@ }, "requirements": {}, "config": { + "input": "agent_config", "accepts": [ "models", "models.base_url", diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index a8c5fb71..70f96169 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1816,6 +1816,9 @@ fn runtime_telemetry_context( Value::String(output_dir.to_string_lossy().into_owned()), ); } + if let Some(native_config) = &telemetry.native_config { + metadata.insert("native_config".to_string(), native_config.clone()); + } if !telemetry.adapter_outputs.is_empty() { metadata.insert( "adapter_outputs".to_string(), @@ -2454,7 +2457,7 @@ mod tests { use std::fs; use super::*; - use crate::config::{ResolveContext, resolve_run_plan_from_config}; + use crate::config::{ResolveContext, TelemetryProvider, resolve_run_plan_from_config}; fn local_host_plan(mode: &str) -> (PathBuf, RunPlan) { local_host_plan_with_relay(mode, false) @@ -2644,6 +2647,26 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn runtime_context_preserves_native_telemetry_config() { + let (root, mut plan) = local_host_plan("success"); + let native_config = serde_json::json!({"components": [{"kind": "observability"}]}); + plan.telemetry_plan = Some(TelemetryPlan { + providers: vec![TelemetryProvider::Native], + relay_enabled: false, + relay_project: None, + relay_output_dir: None, + relay_config: None, + native_config: Some(native_config.clone()), + adapter_outputs: Vec::new(), + }); + + let telemetry = runtime_telemetry_context(&plan, None).expect("telemetry context"); + + assert_eq!(telemetry.metadata["native_config"], native_config); + let _ = fs::remove_dir_all(root); + } + fn stopped_agents() -> Vec { TEST_STOPPED_AGENTS.lock().expect("stop tracker").clone() } diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index f63b9fd4..e7358a81 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -346,7 +346,9 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config "runtime_context": {"runtime_id": "runtime-current"}, } - plugin_config = common_utils.load_relay_plugin_config(payload) + plugin_config = common_utils.load_relay_plugin_config( + payload, model_name="typed/deepagents-model" + ) observability = plugin_config["components"][0]["config"] assert plugin_config["version"] == 1 @@ -371,7 +373,7 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config == "trajectory-{session_id}.atif.json" ) assert observability["atif"]["agent_name"] == "review-agent" - assert observability["atif"]["model_name"] == "nvidia/review-model" + assert observability["atif"]["model_name"] == "typed/deepagents-model" assert Path(observability["atif"]["output_directory"]).is_dir() atof_file = Path(file_sink["output_directory"]) / "events.atof.jsonl" diff --git a/tests/adapters/test_adapter_package_metadata.py b/tests/adapters/test_adapter_package_metadata.py index ceb2c756..cee7fac3 100644 --- a/tests/adapters/test_adapter_package_metadata.py +++ b/tests/adapters/test_adapter_package_metadata.py @@ -80,6 +80,7 @@ def load_pyproject(path: str) -> dict: ( "adapters/deepagents", [ + f"nemo-fabric-adapter-contract == {PACKAGE_VERSION}", f"nemo-fabric-adapters-common == {PACKAGE_VERSION}", "langchain-mcp-adapters>=0.1,<0.3.0", "langchain-openai>=0.3", diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index e43c4131..a4f8544c 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -23,11 +23,16 @@ from unittest.mock import MagicMock import pytest +from nemo_fabric_adapter_contract.codec import ContractValidationError +from nemo_fabric_adapter_contract.models import AgentConfig +from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.deepagents import adapter # noqa: E402 def lifecycle_start_payload(payload: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in payload.items() if key != "request"} + start = {key: value for key, value in payload.items() if key != "request"} + start["config"] = AgentConfig.from_mapping(start["config"]) + return start def lifecycle_invocation(payload: dict[str, Any]) -> dict[str, Any]: @@ -167,10 +172,17 @@ def make(tmp_path: Path, *, runtime_id: str = "run-1") -> dict[str, Any]: "runtime_context": { "runtime_id": runtime_id, "invocation_id": "inv-1", - "environment": {"workspace": str(tmp_path)}, + "request_id": "request-1", + "environment": { + "environment_id": "test-environment", + "provider": "test", + "control_location": "in_env_control", + "workspace": str(tmp_path), + "ownership": "caller_owned", + }, + "artifacts": {}, }, "request": {"input": "hello", "request_id": "request-1"}, - "capability_plan": {}, } return make @@ -338,28 +350,32 @@ def boom(**_kwargs): ) +async def test_runtime_start_requires_validated_agent_config(tmp_path, make_payload): + payload = make_payload(tmp_path) + start = {key: value for key, value in payload.items() if key != "request"} + + with pytest.raises(adapter.lifecycle.LifecycleError, match="validated AgentConfig"): + await adapter.DeepAgentsRuntime().start(start) + + async def test_relay_telemetry_wraps_agent_and_reports_artifacts( tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay ): artifacts = [{"kind": "atof", "path": str(tmp_path / "events.atof.jsonl")}] plugin_config = {"version": 1, "components": []} + load_relay_plugin_config = MagicMock(return_value=plugin_config) monkeypatch.setattr( adapter.common_utils, "load_relay_plugin_config", - lambda _p: plugin_config, + load_relay_plugin_config, ) monkeypatch.setattr( adapter.common_utils, "collect_relay_artifacts", lambda _c: artifacts ) payload = make_payload(tmp_path) - payload["telemetry_plan"] = { - "providers": ["relay"], + payload["runtime_context"]["telemetry"] = { "relay_enabled": True, - "relay_project": None, - "relay_output_dir": None, - "relay_config": {}, - "native_config": None, - "adapter_outputs": [], + "metadata": {"telemetry_providers": ["relay"]}, } output = await invoke_once(payload) @@ -367,6 +383,9 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( assert fake_relay["wrapped"] assert fake_relay["plugin_open"] assert fake_relay["plugin_configs"] == [plugin_config] + assert load_relay_plugin_config.call_args.kwargs == { + "model_name": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning" + } assert output["telemetry"] == { "enabled": True, "provider": "relay", @@ -390,36 +409,36 @@ async def test_native_telemetry_exports_without_artifacts( tmp_path, make_payload, monkeypatch, fake_sdks, fake_relay ): payload = make_payload(tmp_path) - payload["telemetry_plan"] = { - "providers": ["native"], + payload["runtime_context"]["telemetry"] = { "relay_enabled": False, - "relay_project": None, - "relay_output_dir": None, - "relay_config": None, - "native_config": { - "version": 1, - "components": [ - { - "kind": "observability", - "enabled": True, - "config": { - "version": 1, - "opentelemetry": { - "enabled": True, - "endpoint": "http://localhost:4318/v1/traces", + "metadata": { + "telemetry_providers": ["native"], + "native_config": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 1, + "opentelemetry": { + "enabled": True, + "endpoint": "http://localhost:4318/v1/traces", + }, }, - }, - } - ], + } + ], + }, }, - "adapter_outputs": [], } output = await invoke_once(payload) assert fake_relay["wrapped"] assert fake_relay["plugin_open"] - assert fake_relay["plugin_configs"] == [payload["telemetry_plan"]["native_config"]] + assert fake_relay["plugin_configs"] == [ + payload["runtime_context"]["telemetry"]["metadata"]["native_config"] + ] assert output["telemetry"] == { "enabled": True, "provider": "native", @@ -471,16 +490,21 @@ def fake_find_spec( monkeypatch.setattr(importlib_util, "find_spec", fake_find_spec) payload = make_payload(tmp_path) - payload["telemetry_plan"] = { - "providers": ["native"], + payload["runtime_context"]["telemetry"] = { "relay_enabled": False, - "native_config": { - "version": 1, - "components": [ - {"kind": "observability", "enabled": True, "config": {"version": 1}} - ], + "metadata": { + "telemetry_providers": ["native"], + "native_config": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 1}, + } + ], + }, }, - "adapter_outputs": [], } with pytest.raises(RuntimeError, match="nemo-relay.*\\[relay\\]"): @@ -493,16 +517,21 @@ async def test_incomplete_nemo_relay_install_fails_runtime_start( ): monkeypatch.delitem(sys.modules, "nemo_relay.integrations.deepagents") payload = make_payload(tmp_path) - payload["telemetry_plan"] = { - "providers": ["native"], + payload["runtime_context"]["telemetry"] = { "relay_enabled": False, - "native_config": { - "version": 1, - "components": [ - {"kind": "observability", "enabled": True, "config": {"version": 1}} - ], + "metadata": { + "telemetry_providers": ["native"], + "native_config": { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": {"version": 1}, + } + ], + }, }, - "adapter_outputs": [], } with pytest.raises(RuntimeError, match="compatible 'nemo-relay'.*\\[relay\\]"): @@ -599,18 +628,15 @@ async def test_mcp_servers_become_adapter_tools( monkeypatch.setitem(sys.modules, "langchain_mcp_adapters.client", client_mod) payload = make_payload(tmp_path) - # McpServerPlan carries the URL/command in ``url``. - payload["capability_plan"] = { - "native": { - "mcp_servers": { - "fs": {"transport": "streamable-http", "url": "http://localhost:9/mcp"}, - "local": { - "transport": "stdio", - "url": "my-server", - "args": ["--flag", "--config", "repo config.json"], - "env": {"REPO_MCP_MODE": "test"}, - }, - } + payload["config"]["mcp"] = { + "servers": { + "fs": {"transport": "streamable-http", "url": "http://localhost:9/mcp"}, + "local": { + "transport": "stdio", + "url": "my-server", + "args": ["--flag", "--config", "repo config.json"], + "env": {"REPO_MCP_MODE": "test"}, + }, } } @@ -712,7 +738,7 @@ async def test_openai_provider_keeps_openai_endpoint( async def test_skill_paths_map_to_skills(tmp_path, make_payload, fake_sdks): payload = make_payload(tmp_path) - payload["capability_plan"] = {"native": {"skill_paths": ["/skills/a", "/skills/b"]}} + payload["config"]["skills"] = {"paths": ["/skills/a", "/skills/b"]} await invoke_once(payload) @@ -820,7 +846,7 @@ async def test_persistent_runtime_scopes_relay_per_invocation( monkeypatch.setattr( adapter.common_utils, "load_relay_plugin_config", - lambda _payload: plugin_config, + lambda _payload, **_kwargs: plugin_config, ) monkeypatch.setattr( adapter.common_utils, @@ -828,14 +854,9 @@ async def test_persistent_runtime_scopes_relay_per_invocation( lambda _config: artifacts, ) payload = make_payload(tmp_path, runtime_id="run-relay-persistent") - payload["telemetry_plan"] = { - "providers": ["relay"], + payload["runtime_context"]["telemetry"] = { "relay_enabled": True, - "relay_project": None, - "relay_output_dir": None, - "relay_config": {}, - "native_config": None, - "adapter_outputs": ["atif"], + "metadata": {"telemetry_providers": ["relay"], "adapter_outputs": ["atif"]}, } runtime = adapter.DeepAgentsRuntime() @@ -883,8 +904,14 @@ async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload): ] } - settings = payload["config"]["harness"]["settings"] - create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) + agent_config = AgentConfig.from_mapping(payload["config"]) + create_kwargs = await adapter.build_agent_kwargs( + agent_config, + RuntimeContext.from_mapping(payload["runtime_context"]), + payload["base_dir"], + MagicMock(), + agent_config.harness.settings, + ) assert create_kwargs["middleware"], ( "main agent blocked-tools middleware not attached" ) @@ -921,8 +948,14 @@ async def test_default_subagent_is_gated_by_blocked_tools(tmp_path, make_payload payload = make_payload(tmp_path) payload["config"]["tools"] = {"blocked": ["write_file"]} - settings = payload["config"]["harness"]["settings"] - create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) + agent_config = AgentConfig.from_mapping(payload["config"]) + create_kwargs = await adapter.build_agent_kwargs( + agent_config, + RuntimeContext.from_mapping(payload["runtime_context"]), + payload["base_dir"], + MagicMock(), + agent_config.harness.settings, + ) assert [subagent["name"] for subagent in create_kwargs["subagents"]] == [ "general-purpose" @@ -942,9 +975,15 @@ async def test_blocked_tools_reject_unenforceable_subagents( "subagents": [{"name": "worker", **unsupported}] } - settings = payload["config"]["harness"]["settings"] + agent_config = AgentConfig.from_mapping(payload["config"]) with pytest.raises(adapter.AdapterConfigError, match="cannot be enforced"): - await adapter.build_agent_kwargs(payload, MagicMock(), settings) + await adapter.build_agent_kwargs( + agent_config, + RuntimeContext.from_mapping(payload["runtime_context"]), + payload["base_dir"], + MagicMock(), + agent_config.harness.settings, + ) @pytest.mark.parametrize( @@ -1052,11 +1091,9 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): async def test_bad_mcp_transport_fails_runtime_start(tmp_path, make_payload): # A misconfigured MCP server must fail loudly, not be silently dropped. payload = make_payload(tmp_path) - payload["capability_plan"] = { - "native": { - "mcp_servers": { - "bad": {"transport": "carrier-pigeon", "url": "http://x/mcp"} - } + payload["config"]["mcp"] = { + "servers": { + "bad": {"transport": "carrier-pigeon", "url": "http://x/mcp"} } } @@ -1066,11 +1103,11 @@ async def test_bad_mcp_transport_fails_runtime_start(tmp_path, make_payload): async def test_empty_mcp_url_fails_runtime_start(tmp_path, make_payload): payload = make_payload(tmp_path) - payload["capability_plan"] = { - "native": {"mcp_servers": {"bad": {"transport": "streamable_http", "url": ""}}} + payload["config"]["mcp"] = { + "servers": {"bad": {"transport": "streamable_http", "url": ""}} } - with pytest.raises(adapter.AdapterConfigError, match="url"): + with pytest.raises(ContractValidationError, match="url"): await adapter.DeepAgentsRuntime().start(lifecycle_start_payload(payload)) @@ -1142,4 +1179,6 @@ def test_main_serves_persistent_runtime(monkeypatch): adapter.main() - serve.assert_called_once_with(adapter.DeepAgentsRuntime) + serve.assert_called_once_with( + adapter.DeepAgentsRuntime, config_loader=AgentConfig.from_mapping + ) diff --git a/uv.lock b/uv.lock index 5a36ac99..a5b174b9 100644 --- a/uv.lock +++ b/uv.lock @@ -2309,6 +2309,7 @@ dependencies = [ { name = "langchain-mcp-adapters" }, { name = "langchain-openai" }, { name = "langgraph-checkpoint-sqlite" }, + { name = "nemo-fabric-adapter-contract" }, { name = "nemo-fabric-adapters-common" }, ] @@ -2333,6 +2334,7 @@ requires-dist = [ { name = "langgraph", marker = "extra == 'full'", specifier = ">=1.2,<2.0" }, { name = "langgraph", marker = "extra == 'harness'", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, + { name = "nemo-fabric-adapter-contract", editable = "adapter-contract" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, { name = "nemo-relay", marker = "extra == 'relay'", specifier = ">=0.6.0,<0.7" }, { name = "nemo-relay", extras = ["deepagents"], marker = "extra == 'full'", specifier = ">=0.6.0,<0.7" },