From fb403113419c348267df2dbbe7e58af3fb49c2ba Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:09:04 -0700 Subject: [PATCH 1/5] Add typed Relay telemetry config Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- ATTRIBUTIONS-Python.md | 2 +- .../nemo_fabric_adapters/codex_cli/adapter.py | 77 +- .../src/nemo_fabric_adapters/common/hermes.py | 10 +- .../src/nemo_fabric_adapters/common/utils.py | 58 +- adapters/deepagents/README.md | 4 +- adapters/deepagents/pyproject.toml | 2 +- .../deepagents/adapter.py | 22 +- adapters/deepagents/uv.lock | 2 +- .../hermes_cli/adapter.py | 2 +- .../hermes_sdk/adapter.py | 9 +- crates/fabric-core/src/config.rs | 628 ++++++++++++++-- crates/fabric-core/src/lib.rs | 9 +- crates/fabric-core/src/runtime.rs | 52 +- .../api/python-library-reference/index.md | 11 + .../nemo_fabric.models.md | 682 +++++++++++++++++- .../config/enum-capabilitykind.mdx | 2 +- .../config/enum-capabilitytarget.mdx | 2 +- .../config/enum-relayatifstorageconfig.mdx | 162 +++++ .../enum-relayatofendpointfieldnamepolicy.mdx | 123 ++++ .../enum-relayatofendpointtransport.mdx | 130 ++++ .../fabric-core/config/enum-relayatofmode.mdx | 123 ++++ .../config/enum-relayotlptransport.mdx | 123 ++++ .../config/enum-relayunsupportedbehavior.mdx | 130 ++++ .../config/enum-telemetryprovider.mdx | 44 ++ .../config/fn-load-adapter-descriptor.mdx | 2 +- .../config/fn-load-fabric-document.mdx | 2 +- ...n-resolve-effective-config-from-config.mdx | 2 +- ...resolve-effective-config-with-profiles.mdx | 2 +- .../config/fn-resolve-effective-config.mdx | 2 +- .../fn-resolve-run-plan-from-config.mdx | 2 +- ...resolve-run-plan-from-effective-config.mdx | 2 +- .../fn-resolve-run-plan-with-profiles.mdx | 2 +- .../config/fn-resolve-run-plan.mdx | 2 +- .../config/fn-validate-agent-directory.mdx | 2 +- .../fabric-core/config/index.mdx | 17 +- .../config/struct-fabricconfig.mdx | 6 +- .../config/struct-relayatifconfig.mdx | 138 ++++ .../config/struct-relayatofconfig.mdx | 122 ++++ .../config/struct-relayatofendpointconfig.mdx | 114 +++ .../config/struct-relaycomponentconfig.mdx | 106 +++ .../fabric-core/config/struct-relayconfig.mdx | 122 ++++ .../config/struct-relayconfigpolicy.mdx | 110 +++ .../struct-relayobservabilityconfig.mdx | 126 ++++ .../config/struct-relayotlpconfig.mdx | 142 ++++ .../config/struct-telemetryconfig.mdx | 22 +- .../config/struct-telemetryplan.mdx | 10 +- .../config/struct-telemetryproviderconfig.mdx | 106 +++ .../fabric-core/doctor/enum-doctorstatus.mdx | 2 +- .../fabric-core/doctor/fn-doctor-plan.mdx | 2 +- .../fabric-core/doctor/index.mdx | 2 +- .../fabric-core/doctor/struct-doctorcheck.mdx | 2 +- .../doctor/struct-doctorreport.mdx | 2 +- .../fabric-core/error/enum-fabricerror.mdx | 2 +- .../fabric-core/error/index.mdx | 2 +- .../fabric-core/error/type-result.mdx | 2 +- .../fabric-core/fn-version.mdx | 2 +- .../fabric-core/index.mdx | 1 + .../fabric-core/runtime/index.mdx | 2 +- .../fabric-core/schema/index.mdx | 2 +- docs/sdk/python.mdx | 19 + examples/code_review_agent/config.py | 166 ++--- examples/harbor/README.md | 38 +- .../environment/fabric/configs/codex.yaml | 3 +- .../fabric/configs/hermes-relay.yaml | 42 +- .../environment/fabric/configs/hermes.yaml | 3 +- .../environment/fabric/configs/smoke.yaml | 3 +- pyproject.toml | 2 +- python/src/nemo_fabric/__init__.py | 99 +-- python/src/nemo_fabric/models.py | 193 ++++- python/src/nemo_fabric/types.py | 208 +++--- schemas/adapter-invocation.schema.json | 638 +++++++++++++++- schemas/agent.schema.json | 635 +++++++++++++++- schemas/effective-config.schema.json | 635 +++++++++++++++- schemas/profile.schema.json | 8 + schemas/run-plan.schema.json | 638 +++++++++++++++- tests/_utils/utils.py | 6 +- .../adapters/test_adapaters_common_hermes.py | 46 +- tests/adapters/test_codex_cli.py | 107 ++- tests/adapters/test_deepagents.py | 34 +- tests/adapters/test_hermes_cli.py | 13 +- tests/adapters/test_hermes_sdk_adapter.py | 14 +- tests/e2e/test_cli.py | 9 +- tests/fixtures/file-config-agent/agent.yaml | 3 +- .../file-config-agent/profiles/codex-cli.yaml | 3 +- .../file-config-agent/profiles/env-local.yaml | 3 +- .../profiles/env-opensandbox.yaml | 19 +- .../profiles/hermes-cli.yaml | 3 +- .../profiles/hermes-sdk.yaml | 3 +- .../profiles/mcp-github.yaml | 31 +- .../profiles/native-otel.yaml | 28 +- .../profiles/relay-openinference.yaml | 44 +- .../profiles/relay-otel.yaml | 36 +- .../file-config-agent/profiles/relay.yaml | 36 +- tests/fixtures/hermes-cli-agent/agent.yaml | 3 +- .../hermes-cli-agent/profiles/env-local.yaml | 3 +- tests/fixtures/hermes-shim-agent/agent.yaml | 3 +- .../hermes-shim-agent/profiles/env-local.yaml | 3 +- .../harbor-swebench-django-13741.yaml | 3 +- .../profiles/mcp-github.yaml | 5 +- .../profiles/swebench-shim.yaml | 3 +- tests/integrations/test_harbor_runner.py | 62 +- tests/python/test_code_review_example.py | 47 +- tests/python/test_native_sdk.py | 13 +- tests/python/test_sdk_contract.py | 210 ++++-- tests/python/test_typed_config.py | 15 +- uv.lock | 16 +- 106 files changed, 6696 insertions(+), 1051 deletions(-) create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatifconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofendpointconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relaycomponentconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfigpolicy.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayobservabilityconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-relayotlpconfig.mdx create mode 100644 docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig.mdx diff --git a/ATTRIBUTIONS-Python.md b/ATTRIBUTIONS-Python.md index fdb91e1f6..5c75ab2d5 100644 --- a/ATTRIBUTIONS-Python.md +++ b/ATTRIBUTIONS-Python.md @@ -5725,7 +5725,7 @@ OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ``` -## nemo-relay (0.4.0) +## nemo-relay (0.5.0) ### Licenses License: `Apache-2.0` diff --git a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py b/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py index a2eaa8b0f..270a0655d 100755 --- a/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py +++ b/adapters/codex-cli/src/nemo_fabric_adapters/codex_cli/adapter.py @@ -18,7 +18,8 @@ import urllib.request from collections.abc import Mapping from pathlib import Path -from typing import Any, NamedTuple +from typing import Any +from typing import NamedTuple import nemo_fabric_adapters.common.utils as common_utils import tomli_w @@ -87,9 +88,7 @@ def load_thread_id(payload: dict[str, Any], runtime_id: str) -> str | None: value = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as error: raise RuntimeError(f"invalid Codex runtime state in {path}") from error - if not isinstance(value, dict) or value.get("runtime_id") != runtime_id or not value.get( - "thread_id" - ): + if not isinstance(value, dict) or value.get("runtime_id") != runtime_id or not value.get("thread_id"): raise RuntimeError(f"invalid Codex runtime state in {path}") return str(value["thread_id"]) @@ -129,9 +128,7 @@ def build_command( command = resolve_command(payload, settings.get("codex_command") or "codex") sandbox = str(settings.get("sandbox") or "read-only") if sandbox not in SANDBOXES: - raise ValueError( - f"unsupported Codex sandbox {sandbox!r}; expected one of {sorted(SANDBOXES)}" - ) + raise ValueError(f"unsupported Codex sandbox {sandbox!r}; expected one of {sorted(SANDBOXES)}") args = [command, "exec", "--json"] @@ -172,11 +169,10 @@ def config_overrides(settings: dict[str, Any]) -> Mapping[str, Any]: def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: - telemetry = common_utils.telemetry_payload(payload) - if not telemetry.get("enabled") or common_utils.telemetry_provider(payload) != "native": + if "native" not in common_utils.telemetry_providers(payload): return {} - telemetry_config = telemetry.get("config") or {} + telemetry_config = common_utils.native_telemetry_config(payload) components = telemetry_config.get("components") or [] for component in components: if ( @@ -202,13 +198,14 @@ def native_codex_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: if transport == "http_binary": exporter = "otlp-http" protocol = "binary" + elif transport == "grpc": + exporter = "otlp-grpc" + protocol = "grpc" elif transport == "http_json": exporter = "otlp-http" protocol = "json" else: - raise ValueError( - f"unsupported Codex native OpenTelemetry transport {transport!r}" - ) + raise ValueError(f"unsupported Codex native OpenTelemetry transport {transport!r}") otel["trace_exporter"] = { exporter: { "endpoint": endpoint, @@ -232,9 +229,7 @@ def apply_config_overrides( for part in parts[:-1]: existing = target.setdefault(part, {}) if not isinstance(existing, dict): - raise ValueError( - f"Codex config override {dotted_key!r} conflicts with {part!r}" - ) + raise ValueError(f"Codex config override {dotted_key!r} conflicts with {part!r}") target = existing target[parts[-1]] = value @@ -266,14 +261,12 @@ def load_codex_profile(settings: dict[str, Any]) -> dict[str, Any]: def write_config_files(payload: dict[str, Any]) -> CodexSettings: settings = common_utils.settings_payload(payload) - telemetry_provider = common_utils.telemetry_provider(payload) - relay_enabled = ( - telemetry_provider == "relay" - and os.environ.get("FABRIC_RELAY_ENABLED") == "true" - ) + telemetry_providers = common_utils.telemetry_providers(payload) + telemetry_provider = telemetry_providers[0] if telemetry_providers else "relay" + relay_enabled = common_utils.relay_enabled(payload) overrides = config_overrides(settings) config = load_codex_profile(settings) - if telemetry_provider == "native": + if "native" in telemetry_providers: merge_config(config, native_codex_telemetry_config(payload)) codex_profile_name = None @@ -298,9 +291,7 @@ def write_config_files(payload: dict[str, Any]) -> CodexSettings: plugin_config=relay_plugin_config, ) if relay_config_path is None: - raise RuntimeError( - "NeMo Relay configuration did not produce a gateway config" - ) + raise RuntimeError("NeMo Relay configuration did not produce a gateway config") relay_command = resolve_command( payload, @@ -337,7 +328,7 @@ def write_config_files(payload: dict[str, Any]) -> CodexSettings: }, "features": {"hooks": True}, "hooks": hooks, - } + }, ) apply_config_overrides(config, overrides) @@ -363,9 +354,7 @@ def write_config_files(payload: dict[str, Any]) -> CodexSettings: def get_codex_profile_path(payload: dict[str, Any]) -> tuple[str, Path]: runtime_id = common_utils.runtime_context(payload).get("runtime_id") if not runtime_id: - raise RuntimeError( - "runtime_context.runtime_id is required for generated Codex profiles" - ) + raise RuntimeError("runtime_context.runtime_id is required for generated Codex profiles") name = f"fabric-{runtime_id}" return name, codex_home() / f"{name}.config.toml" @@ -392,9 +381,7 @@ def toml_value(value: Any) -> str: try: document = tomli_w.dumps({"value": value}) except TypeError as error: - raise ValueError( - "Codex config override values must be a TOML scalar or array" - ) from error + raise ValueError("Codex config override values must be a TOML scalar or array") from error prefix = "value = " if not document.startswith(prefix): raise ValueError("Codex config override values must be a TOML scalar or array") @@ -491,9 +478,7 @@ def wait_for_relay_gateway( while time.monotonic() < deadline: returncode = process.poll() if returncode is not None: - raise RuntimeError( - f"NeMo Relay gateway exited with status {returncode} before becoming ready" - ) + raise RuntimeError(f"NeMo Relay gateway exited with status {returncode} before becoming ready") try: with urllib.request.urlopen(health_url, timeout=1) as response: if 200 <= response.status < 300: @@ -549,15 +534,8 @@ def start_relay_gateway( def process_timeout(payload: dict[str, Any]) -> float: - value = common_utils.settings_payload(payload).get( - "timeout_seconds", DEFAULT_TIMEOUT_SECONDS - ) - if ( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(value) - or value <= 0 - ): + value = common_utils.settings_payload(payload).get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: raise ValueError("timeout_seconds must be a positive finite number") return float(value) @@ -636,9 +614,7 @@ def run_codex(payload: dict[str, Any]) -> dict[str, Any]: if not thread_id: error = error or "Codex runtime invocation did not return a thread identity" if prior_thread_id and thread_id != prior_thread_id: - error = error or ( - f"Codex resumed thread {thread_id}, expected persisted thread {prior_thread_id}" - ) + error = error or (f"Codex resumed thread {thread_id}, expected persisted thread {prior_thread_id}") if thread_id and not error: save_thread_id(payload, runtime_id, thread_id) @@ -659,9 +635,7 @@ def run_codex(payload: dict[str, Any]) -> dict[str, Any]: } if codex_settings.relay_plugin_config is not None: - relay_artifacts = common_utils.collect_relay_artifacts( - codex_settings.relay_plugin_config - ) + relay_artifacts = common_utils.collect_relay_artifacts(codex_settings.relay_plugin_config) output["relay_runtime"] = { "enabled": True, "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), @@ -676,8 +650,7 @@ def redact_command(command: list[str]) -> list[str]: redacted = list(command) for index, value in enumerate(redacted[:-1]): if value == "--config" and any( - marker in redacted[index + 1].lower() - for marker in ("key", "token", "secret", "password") + marker in redacted[index + 1].lower() for marker in ("key", "token", "secret", "password") ): redacted[index + 1] = "" return redacted diff --git a/adapters/common/src/nemo_fabric_adapters/common/hermes.py b/adapters/common/src/nemo_fabric_adapters/common/hermes.py index a31b9c54c..fb792329d 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/hermes.py +++ b/adapters/common/src/nemo_fabric_adapters/common/hermes.py @@ -40,7 +40,8 @@ def selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: def validate_hermes_telemetry_provider(payload: dict[str, Any]) -> None: - if common_utils.telemetry_provider(payload) != "relay": + providers = common_utils.telemetry_providers(payload) + if any(provider != "relay" for provider in providers): raise ValueError("only relay telemetry is supported for Hermes") @@ -83,10 +84,7 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) mcp_servers = native.get("mcp_servers") or {} if mcp_servers: - config["mcp_servers"] = { - name: hermes_mcp_server_config(server) - for name, server in sorted(mcp_servers.items()) - } + config["mcp_servers"] = {name: hermes_mcp_server_config(server) for name, server in sorted(mcp_servers.items())} if "enabled_toolsets" in settings: config["platform_toolsets"] = { @@ -150,7 +148,7 @@ def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: def configure_hermes_relay(payload: dict[str, Any]) -> dict[str, Any] | None: - if os.environ.get("FABRIC_RELAY_ENABLED") != "true": + if not common_utils.relay_enabled(payload): return None relay_plugin_config = common_utils.load_relay_plugin_config(payload) diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 644b7fda1..fc590d370 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -9,17 +9,16 @@ import os import sys from pathlib import Path -from typing import Any, TYPE_CHECKING +from typing import TYPE_CHECKING +from typing import Any if TYPE_CHECKING: from nemo_relay import plugin - from nemo_relay.observability import ( - AtifConfig, - AtofConfig, - HttpStorageConfig, - OtlpConfig, - S3StorageConfig, - ) + from nemo_relay.observability import AtifConfig + from nemo_relay.observability import AtofConfig + from nemo_relay.observability import HttpStorageConfig + from nemo_relay.observability import OtlpConfig + from nemo_relay.observability import S3StorageConfig def current_virtualenv() -> Path | None: @@ -40,7 +39,7 @@ def virtualenv_subprocess_env() -> dict[str, str]: env = os.environ.copy() virtualenv = current_virtualenv() if virtualenv is None: - return env + return env scripts = virtualenv / ("Scripts" if os.name == "nt" else "bin") path = env.get("PATH") @@ -109,13 +108,25 @@ def models_payload(payload: dict[str, Any]) -> dict[str, Any]: return fabric_config(payload).get("models") or payload.get("models") or {} -def telemetry_payload(payload: dict[str, Any]) -> dict[str, Any]: - telemetry = fabric_config(payload).get("telemetry") or payload.get("telemetry") or {} - return telemetry if isinstance(telemetry, dict) else {} +def telemetry_plan(payload: dict[str, Any]) -> dict[str, Any]: + plan = payload.get("telemetry_plan") or {} + return plan if isinstance(plan, dict) else {} + + +def telemetry_providers(payload: dict[str, Any]) -> list[str]: + providers = telemetry_plan(payload).get("providers") + if isinstance(providers, list): + return [str(provider) for provider in providers if str(provider)] + return [] -def telemetry_provider(payload: dict[str, Any]) -> str: - return str(telemetry_payload(payload).get("provider") or "relay") +def relay_enabled(payload: dict[str, Any]) -> bool: + return telemetry_plan(payload).get("relay_enabled") is True + + +def native_telemetry_config(payload: dict[str, Any]) -> dict[str, Any]: + config = telemetry_plan(payload).get("native_config") or {} + return config if isinstance(config, dict) else {} def capability_plan(payload: dict[str, Any]) -> dict[str, Any]: @@ -135,6 +146,7 @@ def normalize_list(value: Any) -> list[str]: def dump_yaml(value: dict[str, Any]) -> str: try: import yaml + return yaml.safe_dump(value, sort_keys=False) except ImportError: return json.dumps(value, indent=2, sort_keys=False) + "\n" @@ -201,11 +213,9 @@ def normalize_relay_output_dirs(plugin_config: dict[str, Any], payload: dict[str def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfig: from nemo_relay import plugin - from nemo_relay.observability import ( - ComponentSpec, - ConfigPolicy, - ObservabilityConfig, - ) + from nemo_relay.observability import ComponentSpec + from nemo_relay.observability import ConfigPolicy + from nemo_relay.observability import ObservabilityConfig components: list[Any] = [] for component in plugin_config.get("components", []): @@ -264,7 +274,8 @@ def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfi def _relay_api_atof_config(value: Any) -> AtofConfig | None: if not isinstance(value, dict): return None - from nemo_relay.observability import AtofConfig, AtofEndpointConfig + from nemo_relay.observability import AtofConfig + from nemo_relay.observability import AtofEndpointConfig endpoint_configs = value.get("endpoints") endpoints = None @@ -296,11 +307,7 @@ def _relay_api_atif_config(value: Any) -> AtifConfig | None: storage_configs = value.get("storage") storage = None if isinstance(storage_configs, list): - storage = [ - _relay_api_storage_config(item) - for item in storage_configs - if isinstance(item, dict) - ] + storage = [_relay_api_storage_config(item) for item in storage_configs if isinstance(item, dict)] return AtifConfig( enabled=bool(value.get("enabled", False)), agent_name=value.get("agent_name", "NeMo Relay"), @@ -409,7 +416,6 @@ def write_relay_configs( raise RuntimeError("tomli_w is not installed") from e - def _relay_model_name(payload: dict[str, Any]) -> str: settings = settings_payload(payload) models = models_payload(payload) diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index 353b5bf7b..c05ad54ea 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -134,11 +134,11 @@ async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: ## Telemetry -- **Relay** (`telemetry.provider: relay`): the agent is wrapped with +- **Relay** (`telemetry.providers.relay`): the agent is wrapped with `nemo_relay.integrations.deepagents.add_nemo_relay_integration`, emitting ATOF/ATIF artifacts referenced in the `ArtifactManifest`. OTel/OpenInference export is available through the relay plugin config (see the `relay-otel` and `relay-openinference` profiles). -- **Native** (`telemetry.provider: native`): the `telemetry.config` +- **Native** (`telemetry.providers.native.config`): the provider config OpenTelemetry/OpenInference exporter is applied and spans export directly to the configured collector, without writing ATOF/ATIF relay artifacts. diff --git a/adapters/deepagents/pyproject.toml b/adapters/deepagents/pyproject.toml index ec99cb8f4..911df9629 100644 --- a/adapters/deepagents/pyproject.toml +++ b/adapters/deepagents/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ # Requires 3.x: the 2.x line is incompatible with the langgraph core deepagents # pulls (AsyncSqliteSaver fails with 'Connection has no attribute is_alive'). "langgraph-checkpoint-sqlite>=3.0,<4.0", - "nemo-relay~=0.4", + "nemo-relay~=0.5.0", ] [tool.setuptools.packages.find] diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index b48dd4012..bdc191977 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -19,7 +19,8 @@ import shlex import uuid from pathlib import Path -from typing import Any, NamedTuple +from typing import Any +from typing import NamedTuple import nemo_fabric_adapters.common.utils as common_utils @@ -451,8 +452,9 @@ def _gate_subagent(subagent: Any, allowed: set[str]) -> Any: async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: settings = common_utils.settings_payload(payload) request = payload.get("request") or {} - telemetry_provider = common_utils.telemetry_provider(payload) - relay_enabled = os.environ.get("FABRIC_RELAY_ENABLED", "").strip().lower() == "true" + telemetry_providers = common_utils.telemetry_providers(payload) + relay_enabled = common_utils.relay_enabled(payload) + telemetry_provider = "relay" if relay_enabled else ("native" if "native" in telemetry_providers else "") user_message = request.get("input") or "" if not isinstance(user_message, str): @@ -594,9 +596,10 @@ def resolve_observability( """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.config`` from - the payload (e.g. an OpenTelemetry/OpenInference exporter) and exports spans - directly to the configured collector without writing relay artifacts. + collects ATOF/ATIF artifacts. Native telemetry reads + ``telemetry_plan.native_config`` from the payload (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": @@ -605,10 +608,9 @@ def resolve_observability( "deepagents.observability/nemo_relay", True, ) - telemetry = common_utils.telemetry_payload(payload) - if telemetry.get("enabled") and telemetry_provider == "native": - native_config = telemetry.get("config") or {} - if isinstance(native_config, dict) and native_config.get("components"): + if telemetry_provider == "native": + native_config = common_utils.native_telemetry_config(payload) + if native_config.get("components"): return Observability(native_config, "deepagents.observability/native", False) return None diff --git a/adapters/deepagents/uv.lock b/adapters/deepagents/uv.lock index 71c6fb71f..551390347 100644 --- a/adapters/deepagents/uv.lock +++ b/adapters/deepagents/uv.lock @@ -861,7 +861,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, { name = "nemo-fabric-adapters-common", editable = "../common" }, - { name = "nemo-relay", specifier = "~=0.4" }, + { name = "nemo-relay", specifier = "~=0.5.0" }, ] [[package]] diff --git a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py index ee447f864..b3fabd951 100755 --- a/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py +++ b/adapters/hermes-cli/src/nemo_fabric_adapters/hermes_cli/adapter.py @@ -63,7 +63,7 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]: hermes_config_path, hermes_config = hermes_common.write_hermes_config( payload, hermes_home, - relay_enabled=relay_plugin_config is not None, + relay_enabled=common_utils.relay_enabled(payload), ) if settings.get("prepare_runtime_state", True): diff --git a/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py b/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py index 72f291231..81ceb5aa5 100755 --- a/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py +++ b/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py @@ -81,7 +81,7 @@ async def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]: os.environ["HERMES_SESSION_SOURCE"] = "fabric" os.environ.setdefault("TERMINAL_ENV", settings.get("terminal_backend", "local")) os.environ.setdefault("TERMINAL_TIMEOUT", str(settings.get("terminal_timeout", 60))) - relay_enabled = os.environ.get("FABRIC_RELAY_ENABLED", "").strip().lower() == "true" + relay_enabled = common_utils.relay_enabled(payload) relay_plugin_config = None if relay_enabled: @@ -118,9 +118,7 @@ async def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]: from nemo_relay import plugin async with plugin.plugin(relay_api_config): - (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes( - **hermes_kwargs - ) + (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) else: (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) @@ -169,7 +167,8 @@ def _invoke_hermes( relay_plugin_config: dict[str, Any] | None, ) -> tuple[dict[str, Any], list[str] | None, list[dict[str, str]], str]: from hermes_cli.config import load_config - from hermes_cli.plugins import discover_plugins, invoke_hook + from hermes_cli.plugins import discover_plugins + from hermes_cli.plugins import invoke_hook from hermes_state import SessionDB from run_agent import AIAgent diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 1c9efb9ee..6b41f825e 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -62,6 +62,9 @@ pub struct FabricConfig { /// Telemetry configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub telemetry: Option, + /// First-class NeMo Relay integration configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay: Option, /// Optional profile discovery config. #[serde(default, skip_serializing_if = "ProfileRegistryConfig::is_empty")] pub profiles: ProfileRegistryConfig, @@ -408,6 +411,8 @@ struct ProfileConfigSchema { mcp: Option>, /// Partial telemetry overlay. telemetry: Option>, + /// Partial Relay integration overlay. + relay: Option>, /// Additive config overlays. #[serde(flatten)] extensions: BTreeMap, @@ -604,29 +609,399 @@ pub enum McpExposure { /// Telemetry configuration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct TelemetryConfig { - /// Whether telemetry is enabled for this run. + /// Telemetry providers enabled for this run. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub providers: BTreeMap, + /// Additive telemetry fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// Provider-specific telemetry configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TelemetryProviderConfig { + /// Provider-specific pass-through config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Additive provider fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// NeMo Relay integration configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayConfig { + /// Optional project name for Relay backends. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + /// Optional Relay output directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_dir: Option, + /// Relay observability component configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observability: Option, + /// Additional Relay plugin components. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub components: Vec, + /// Relay plugin validation policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Additive Relay fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// Generic NeMo Relay plugin component configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayComponentConfig { + /// Registered Relay plugin kind. + pub kind: String, + /// Whether this Relay component should be activated. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Component-local Relay plugin config. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub config: BTreeMap, + /// Additive component fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// NeMo Relay observability component configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayObservabilityConfig { + /// Relay observability config version. + #[serde(default = "default_relay_config_version")] + pub version: u32, + /// ATOF export configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atof: Option, + /// ATIF export configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub atif: Option, + /// OpenTelemetry export configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opentelemetry: Option, + /// OpenInference export configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub openinference: Option, + /// Relay config validation policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy: Option, + /// Additive observability fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +impl Default for RelayObservabilityConfig { + fn default() -> Self { + Self { + version: default_relay_config_version(), + atof: None, + atif: None, + opentelemetry: None, + openinference: None, + policy: None, + extensions: BTreeMap::new(), + } + } +} + +/// Relay ATOF export configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayAtofConfig { + /// Whether ATOF export is enabled. #[serde(default)] pub enabled: bool, - /// Telemetry provider responsible for runtime integration. + /// Directory used for ATOF files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_directory: Option, + /// ATOF file name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + /// File write mode. #[serde(default)] - pub provider: TelemetryProvider, - /// Optional project name for telemetry backends. + pub mode: RelayAtofMode, + /// Optional remote ATOF endpoints. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, + /// Additive ATOF fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// Relay ATOF endpoint configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayAtofEndpointConfig { + /// Endpoint URL. + pub url: String, + /// Endpoint transport. + #[serde(default)] + pub transport: RelayAtofEndpointTransport, + /// Endpoint headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + /// Request timeout in milliseconds. + #[serde(default = "default_relay_timeout_millis")] + pub timeout_millis: u64, + /// Field-name handling policy. + #[serde(default)] + pub field_name_policy: RelayAtofEndpointFieldNamePolicy, + /// Additive endpoint fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +/// Relay ATIF export configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayAtifConfig { + /// Whether ATIF export is enabled. + #[serde(default)] + pub enabled: bool, + /// Agent name written into ATIF. + #[serde(default = "default_relay_atif_agent_name")] + pub agent_name: String, + /// Agent version written into ATIF. #[serde(default, skip_serializing_if = "Option::is_none")] - pub project: Option, - /// Optional telemetry output directory. + pub agent_version: Option, + /// Model name written into ATIF. + #[serde(default = "default_relay_atif_model_name")] + pub model_name: String, + /// Tool definitions written into ATIF. #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_dir: Option, - /// Pass-through telemetry backend config. + pub tool_definitions: Option>, + /// Extra ATIF metadata. #[serde(default, skip_serializing_if = "Option::is_none")] - pub config: Option, - /// Additive telemetry fields. + pub extra: Option, + /// Directory used for ATIF files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_directory: Option, + /// ATIF file name template. + #[serde(default = "default_relay_atif_filename_template")] + pub filename_template: String, + /// Optional ATIF remote storage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage: Option>, + /// Additive ATIF fields. #[serde(default, flatten)] pub extensions: BTreeMap, } -/// Telemetry runtime provider. +impl Default for RelayAtifConfig { + fn default() -> Self { + Self { + enabled: false, + agent_name: default_relay_atif_agent_name(), + agent_version: None, + model_name: default_relay_atif_model_name(), + tool_definitions: None, + extra: None, + output_directory: None, + filename_template: default_relay_atif_filename_template(), + storage: None, + extensions: BTreeMap::new(), + } + } +} + +/// Relay ATIF remote storage configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RelayAtifStorageConfig { + /// Upload ATIF artifacts to HTTP storage. + Http { + /// HTTP storage endpoint. + #[serde(default)] + endpoint: String, + /// Static HTTP headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + headers: BTreeMap, + /// Environment-variable-backed HTTP headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + header_env: BTreeMap, + /// Request timeout in milliseconds. + #[serde(default = "default_relay_timeout_millis")] + timeout_millis: u64, + /// Additive HTTP storage fields. + #[serde(default, flatten)] + extensions: BTreeMap, + }, + /// Upload ATIF artifacts to S3-compatible storage. + S3 { + /// S3 bucket name. + #[serde(default)] + bucket: String, + /// Optional S3 object key prefix. + #[serde(default, skip_serializing_if = "Option::is_none")] + key_prefix: Option, + /// AWS access key id. + #[serde(default, skip_serializing_if = "Option::is_none")] + access_key_id: Option, + /// Environment variable containing the AWS secret access key. + #[serde(default, skip_serializing_if = "Option::is_none")] + secret_access_key_var: Option, + /// Environment variable containing the AWS session token. + #[serde(default, skip_serializing_if = "Option::is_none")] + session_token_var: Option, + /// AWS region. + #[serde(default, skip_serializing_if = "Option::is_none")] + region: Option, + /// S3-compatible endpoint URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + endpoint_url: Option, + /// Allow HTTP endpoints for S3-compatible storage. + #[serde(default, skip_serializing_if = "Option::is_none")] + allow_http: Option, + /// Additive S3 storage fields. + #[serde(default, flatten)] + extensions: BTreeMap, + }, +} + +/// Relay OpenTelemetry/OpenInference export configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayOtlpConfig { + /// Whether OTLP export is enabled. + #[serde(default)] + pub enabled: bool, + /// OTLP transport. + #[serde(default)] + pub transport: RelayOtlpTransport, + /// OTLP endpoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// OTLP headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + /// OTLP resource attributes. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub resource_attributes: BTreeMap, + /// OTLP service name. + #[serde(default = "default_relay_service_name")] + pub service_name: String, + /// OTLP service namespace. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_namespace: Option, + /// OTLP service version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_version: Option, + /// OTLP instrumentation scope. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instrumentation_scope: Option, + /// Request timeout in milliseconds. + #[serde(default = "default_relay_timeout_millis")] + pub timeout_millis: u64, + /// Additive OTLP fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + +impl Default for RelayOtlpConfig { + fn default() -> Self { + Self { + enabled: false, + transport: RelayOtlpTransport::default(), + endpoint: None, + headers: BTreeMap::new(), + resource_attributes: BTreeMap::new(), + service_name: default_relay_service_name(), + service_namespace: None, + service_version: None, + instrumentation_scope: None, + timeout_millis: default_relay_timeout_millis(), + extensions: BTreeMap::new(), + } + } +} + +/// Relay validation policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayConfigPolicy { + /// Policy for unknown components. + #[serde(default)] + pub unknown_component: RelayUnsupportedBehavior, + /// Policy for unknown fields. + #[serde(default)] + pub unknown_field: RelayUnsupportedBehavior, + /// Policy for unsupported values. + #[serde(default = "default_relay_unsupported_value_behavior")] + pub unsupported_value: RelayUnsupportedBehavior, +} + +impl Default for RelayConfigPolicy { + fn default() -> Self { + Self { + unknown_component: RelayUnsupportedBehavior::default(), + unknown_field: RelayUnsupportedBehavior::default(), + unsupported_value: default_relay_unsupported_value_behavior(), + } + } +} + +/// Relay unsupported/unknown config handling. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayUnsupportedBehavior { + /// Ignore the unsupported or unknown value. + Ignore, + /// Warn on the unsupported or unknown value. + #[default] + Warn, + /// Error on the unsupported or unknown value. + Error, +} + +/// Relay ATOF file mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayAtofMode { + /// Append to an existing ATOF file. + #[default] + Append, + /// Overwrite an existing ATOF file. + Overwrite, +} + +/// Relay ATOF endpoint transport. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayAtofEndpointTransport { + /// HTTP POST transport. + #[default] + HttpPost, + /// WebSocket transport. + Websocket, + /// NDJSON transport. + Ndjson, +} + +/// Relay ATOF endpoint field-name policy. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] +pub enum RelayAtofEndpointFieldNamePolicy { + /// Preserve field names. + #[default] + Preserve, + /// Replace dots in field names. + ReplaceDots, +} + +/// Relay OTLP transport. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayOtlpTransport { + /// OTLP HTTP binary transport. + #[default] + HttpBinary, + /// OTLP gRPC transport. + Grpc, +} + +/// Telemetry runtime provider. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] pub enum TelemetryProvider { /// Use NeMo Relay for telemetry integration. #[default] @@ -645,6 +1020,38 @@ impl TelemetryProvider { } } +fn default_relay_config_version() -> u32 { + 1 +} + +fn default_enabled() -> bool { + true +} + +fn default_relay_atif_agent_name() -> String { + "NeMo Relay".to_string() +} + +fn default_relay_atif_model_name() -> String { + "unknown".to_string() +} + +fn default_relay_atif_filename_template() -> String { + "nemo-relay-atif-{session_id}.json".to_string() +} + +fn default_relay_timeout_millis() -> u64 { + 3000 +} + +fn default_relay_service_name() -> String { + "nemo-relay".to_string() +} + +fn default_relay_unsupported_value_behavior() -> RelayUnsupportedBehavior { + RelayUnsupportedBehavior::Error +} + /// Load a Fabric document from an agent directory or single agent config. pub fn load_fabric_document(path: impl AsRef) -> Result { let path = path.as_ref(); @@ -1271,18 +1678,71 @@ fn resolve_telemetry_plan( adapter_descriptor: Option<&AdapterDescriptor>, ) -> Option { let telemetry = config.telemetry.as_ref()?; + if telemetry.providers.is_empty() { + return None; + } + let relay_provider = telemetry.providers.get(&TelemetryProvider::Relay); + let native_provider = telemetry.providers.get(&TelemetryProvider::Native); + let relay = config.relay.as_ref(); + let relay_enabled = relay_provider.is_some(); + let providers = [TelemetryProvider::Relay, TelemetryProvider::Native] + .into_iter() + .filter(|provider| telemetry.providers.contains_key(provider)) + .collect(); Some(TelemetryPlan { - provider: telemetry.provider, - relay_enabled: telemetry.enabled && telemetry.provider == TelemetryProvider::Relay, - relay_project: telemetry.project.clone(), - relay_output_dir: telemetry.output_dir.clone(), - relay_config: telemetry.config.clone(), + providers, + relay_enabled, + relay_project: relay_enabled + .then(|| relay.and_then(|relay| relay.project.clone())) + .flatten(), + relay_output_dir: relay_enabled + .then(|| relay.and_then(|relay| relay.output_dir.clone())) + .flatten(), + relay_config: relay_enabled + .then(|| resolve_relay_plugin_config(relay)) + .flatten(), + native_config: native_provider.and_then(|provider| provider.config.clone()), adapter_outputs: adapter_descriptor .map(|descriptor| descriptor.telemetry.supports.clone()) .unwrap_or_default(), }) } +fn resolve_relay_plugin_config(relay: Option<&RelayConfig>) -> Option { + let relay = relay?; + let mut components = Vec::new(); + + if let Some(observability) = relay.observability.as_ref() { + components.push(serde_json::json!({ + "kind": "observability", + "enabled": true, + "config": serde_json::to_value(observability).unwrap_or(Value::Null), + })); + } + + for component in &relay.components { + components.push(serde_json::to_value(component).unwrap_or(Value::Null)); + } + + if !components.is_empty() || relay.policy.is_some() { + let mut plugin_config = serde_json::json!({ + "version": 1, + "components": components, + }); + if let Some(policy) = relay.policy.as_ref() + && let Some(object) = plugin_config.as_object_mut() + { + object.insert( + "policy".to_string(), + serde_json::to_value(policy).unwrap_or(Value::Null), + ); + } + return Some(plugin_config); + } + + None +} + fn resolve_path(root: &Path, path: &Path) -> PathBuf { let resolved = if path.is_absolute() { path.to_path_buf() @@ -1488,8 +1948,8 @@ pub struct McpServerPlan { /// Resolved telemetry plan. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct TelemetryPlan { - /// Telemetry provider selected for this run. - pub provider: TelemetryProvider, + /// Telemetry providers selected for this run. + pub providers: Vec, /// Whether Relay is enabled. pub relay_enabled: bool, /// Relay project, when configured. @@ -1501,6 +1961,9 @@ pub struct TelemetryPlan { /// Relay pass-through config. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_config: Option, + /// Native telemetry pass-through config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_config: Option, /// Telemetry outputs declared by the selected adapter descriptor. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub adapter_outputs: Vec, @@ -1602,7 +2065,7 @@ runtime: } #[test] - fn telemetry_provider_defaults_to_relay() { + fn relay_telemetry_provider_is_enabled_by_presence() { let config: FabricConfig = serde_yaml::from_str( r#" schema_version: fabric.agent/v1alpha1 @@ -1612,27 +2075,23 @@ harness: adapter_id: nvidia.fabric.hermes.sdk runtime: telemetry: - enabled: true - config: - exporter: test + providers: + relay: {} "#, ) - .expect("config with default telemetry provider"); + .expect("config with relay telemetry provider"); let telemetry = config.telemetry.as_ref().expect("telemetry config"); let plan = resolve_telemetry_plan(&config, None).expect("telemetry plan"); - assert_eq!(telemetry.provider, TelemetryProvider::Relay); - assert_eq!(plan.provider, TelemetryProvider::Relay); + assert!(telemetry.providers.contains_key(&TelemetryProvider::Relay)); + assert_eq!(plan.providers, vec![TelemetryProvider::Relay]); assert!(plan.relay_enabled); - assert_eq!( - plan.relay_config, - Some(serde_json::json!({"exporter": "test"})) - ); + assert_eq!(plan.relay_config, None); } #[test] - fn native_telemetry_provider_skips_relay_and_preserves_config() { + fn native_telemetry_provider_skips_relay_config() { let config: FabricConfig = serde_yaml::from_str( r#" schema_version: fabric.agent/v1alpha1 @@ -1642,30 +2101,113 @@ harness: adapter_id: nvidia.fabric.hermes.sdk runtime: telemetry: - enabled: true - provider: native - config: - exporter: test + providers: + native: + config: + exporter: test +relay: + project: relay-project + output_dir: ./relay-output "#, ) .expect("config with native telemetry provider"); let plan = resolve_telemetry_plan(&config, None).expect("telemetry plan"); - assert_eq!(plan.provider, TelemetryProvider::Native); + assert_eq!(plan.providers, vec![TelemetryProvider::Native]); assert!(!plan.relay_enabled); + assert_eq!(plan.relay_project, None); + assert_eq!(plan.relay_output_dir, None); + assert_eq!(plan.relay_config, None); assert_eq!( - plan.relay_config, + plan.native_config, Some(serde_json::json!({"exporter": "test"})) ); } #[test] - fn telemetry_provider_rejects_unknown_values() { + fn relay_telemetry_config_generates_relay_plugin_config() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes.sdk +runtime: +telemetry: + providers: + relay: {} +relay: + project: typed-project + output_dir: ./typed-relay + observability: + atof: + enabled: true + output_directory: ./typed-relay + filename: events.atof.jsonl + mode: overwrite + atif: + enabled: true + output_directory: ./typed-relay + filename_template: trajectory-{session_id}.atif.json + agent_name: code-review-agent + opentelemetry: + enabled: true + endpoint: http://localhost:4318/v1/traces + components: + - kind: switchyard + enabled: true + config: + route: canary + policy: + unknown_component: error +"#, + ) + .expect("config with typed relay telemetry"); + + let plan = resolve_telemetry_plan(&config, None).expect("telemetry plan"); + let relay_config = plan.relay_config.expect("relay plugin config"); + + assert_eq!(plan.relay_project.as_deref(), Some("typed-project")); + assert_eq!(plan.relay_output_dir, Some(PathBuf::from("./typed-relay"))); + assert_eq!(relay_config["version"], serde_json::json!(1)); + assert_eq!( + relay_config["components"][0]["kind"], + serde_json::json!("observability") + ); + assert_eq!( + relay_config["components"][0]["config"]["atof"]["mode"], + serde_json::json!("overwrite") + ); + assert_eq!( + relay_config["components"][0]["config"]["atif"]["agent_name"], + serde_json::json!("code-review-agent") + ); + assert_eq!( + relay_config["components"][0]["config"]["opentelemetry"]["endpoint"], + serde_json::json!("http://localhost:4318/v1/traces") + ); + assert_eq!( + relay_config["components"][1], + serde_json::json!({ + "kind": "switchyard", + "enabled": true, + "config": {"route": "canary"} + }) + ); + assert_eq!( + relay_config["policy"]["unknown_component"], + serde_json::json!("error") + ); + } + + #[test] + fn telemetry_provider_rejects_unknown_provider_keys() { let result = serde_yaml::from_str::( r#" -enabled: true -provider: unsupported +providers: + unsupported: {} "#, ); @@ -1758,8 +2300,8 @@ provider: unsupported plan.config .telemetry .as_ref() - .map(|telemetry| telemetry.enabled), - Some(false) + .map(|telemetry| telemetry.providers.is_empty()), + None ); } @@ -2023,7 +2565,7 @@ mcp: plan.telemetry_plan .as_ref() .map(|telemetry| telemetry.relay_enabled), - Some(false) + None ); let profiles = vec!["env_local".to_string(), "env_opensandbox".to_string()]; @@ -2073,7 +2615,7 @@ mcp: plan.telemetry_plan .as_ref() .map(|telemetry| telemetry.relay_enabled), - Some(false) + None ); assert!( plan.capability_plan diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index d24ab5dcb..f7d2be22a 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -16,10 +16,11 @@ pub use config::{ FabricDocument, HarnessConfig, McpConfig, McpExposure, McpServerPlan, MetadataConfig, ModelConfig, ProfileConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, - TelemetryProvider, load_adapter_descriptor, load_fabric_document, resolve_effective_config, - resolve_effective_config_from_config, resolve_effective_config_with_profiles, resolve_run_plan, - resolve_run_plan_from_config, resolve_run_plan_from_effective_config, - resolve_run_plan_with_profiles, validate_agent_directory, + TelemetryProvider, TelemetryProviderConfig, load_adapter_descriptor, load_fabric_document, + resolve_effective_config, resolve_effective_config_from_config, + resolve_effective_config_with_profiles, resolve_run_plan, resolve_run_plan_from_config, + resolve_run_plan_from_effective_config, resolve_run_plan_with_profiles, + validate_agent_directory, }; pub use doctor::{DoctorCheck, DoctorReport, DoctorStatus, doctor_plan}; pub use error::{FabricError, Result}; diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index b51744879..c0d2cb2f2 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1280,8 +1280,14 @@ fn runtime_telemetry_context( let telemetry = plan.telemetry_plan.as_ref()?; let mut metadata = BTreeMap::new(); metadata.insert( - "telemetry_provider".to_string(), - Value::String(telemetry.provider.as_str().to_string()), + "telemetry_providers".to_string(), + Value::Array( + telemetry + .providers + .iter() + .map(|provider| Value::String(provider.as_str().to_string())) + .collect(), + ), ); if let Some(project) = &telemetry.relay_project { metadata.insert("relay_project".to_string(), Value::String(project.clone())); @@ -1795,8 +1801,14 @@ fn telemetry_ref( let telemetry = plan.telemetry_plan.as_ref()?; let mut metadata = BTreeMap::new(); metadata.insert( - "telemetry_provider".to_string(), - Value::String(telemetry.provider.as_str().to_string()), + "telemetry_providers".to_string(), + Value::Array( + telemetry + .providers + .iter() + .map(|provider| Value::String(provider.as_str().to_string())) + .collect(), + ), ); if let Some(project) = &telemetry.relay_project { metadata.insert("relay_project".to_string(), Value::String(project.clone())); @@ -2080,10 +2092,13 @@ environment: config.push_str( r#" telemetry: - enabled: true - provider: native - config: - exporter: test + providers: + native: + config: + exporter: test +relay: + project: relay-project + output_dir: ./relay-output "#, ); fs::write(&config_path, config).expect("write native telemetry config"); @@ -2126,16 +2141,29 @@ telemetry: .iter() .any(|artifact| artifact.name == "relay_config") ); - assert_eq!(payload["telemetry_plan"]["provider"], "native"); + assert_eq!( + payload["telemetry_plan"]["providers"], + serde_json::json!(["native"]) + ); assert_eq!(payload["telemetry_plan"]["relay_enabled"], false); assert_eq!( - payload["effective_config"]["config"]["telemetry"]["config"], + payload["effective_config"]["config"]["telemetry"]["providers"]["native"]["config"], serde_json::json!({"exporter": "test"}) ); assert!(payload["runtime_context"]["telemetry"].get("env").is_none()); assert_eq!( - payload["runtime_context"]["telemetry"]["metadata"]["telemetry_provider"], - "native" + payload["runtime_context"]["telemetry"]["metadata"]["telemetry_providers"], + serde_json::json!(["native"]) + ); + assert!( + payload["runtime_context"]["telemetry"]["metadata"] + .get("relay_project") + .is_none() + ); + assert!( + payload["runtime_context"]["telemetry"]["metadata"] + .get("relay_output_dir") + .is_none() ); let _ = fs::remove_dir_all(root); diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index e0d90341d..c2b93b66c 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -31,10 +31,21 @@ SPDX-License-Identifier: Apache-2.0 */} - [`models.MetadataConfig`](./nemo_fabric.models.md#class-metadataconfig): Human-readable agent identity. - [`models.ModelConfig`](./nemo_fabric.models.md#class-modelconfig): Model alias configuration. - [`models.ProfileRegistryConfig`](./nemo_fabric.models.md#class-profileregistryconfig): Profile discovery config for portable file-backed agent packages. +- [`models.RelayAtifConfig`](./nemo_fabric.models.md#class-relayatifconfig): NeMo Relay ATIF export configuration. +- [`models.RelayAtofConfig`](./nemo_fabric.models.md#class-relayatofconfig): NeMo Relay ATOF export configuration. +- [`models.RelayAtofEndpointConfig`](./nemo_fabric.models.md#class-relayatofendpointconfig): NeMo Relay ATOF remote endpoint configuration. +- [`models.RelayComponentConfig`](./nemo_fabric.models.md#class-relaycomponentconfig): Generic NeMo Relay plugin component configuration. +- [`models.RelayConfig`](./nemo_fabric.models.md#class-relayconfig): First-class NeMo Relay integration configuration. +- [`models.RelayConfigPolicy`](./nemo_fabric.models.md#class-relayconfigpolicy): NeMo Relay config validation policy. +- [`models.RelayHttpStorageConfig`](./nemo_fabric.models.md#class-relayhttpstorageconfig): NeMo Relay ATIF HTTP storage configuration. +- [`models.RelayObservabilityConfig`](./nemo_fabric.models.md#class-relayobservabilityconfig): NeMo Relay observability component configuration. +- [`models.RelayOtlpConfig`](./nemo_fabric.models.md#class-relayotlpconfig): NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference. +- [`models.RelayS3StorageConfig`](./nemo_fabric.models.md#class-relays3storageconfig): NeMo Relay ATIF S3 storage configuration. - [`models.RunRequest`](./nemo_fabric.models.md#class-runrequest): One validated Fabric invocation request. - [`models.RuntimeConfig`](./nemo_fabric.models.md#class-runtimeconfig): Runtime input/output contract. - [`models.SkillConfig`](./nemo_fabric.models.md#class-skillconfig): Skill capability configuration. - [`models.TelemetryConfig`](./nemo_fabric.models.md#class-telemetryconfig): Telemetry configuration. +- [`models.TelemetryProviderConfig`](./nemo_fabric.models.md#class-telemetryproviderconfig): Provider-specific telemetry configuration. - [`types.AdapterInfo`](./nemo_fabric.types.md#class-adapterinfo): Resolved adapter identity attached to a run plan. - [`types.ArtifactManifest`](./nemo_fabric.types.md#class-artifactmanifest): Normalized collection of artifacts produced by a run. - [`types.ArtifactRef`](./nemo_fabric.types.md#class-artifactref): Reference to one artifact produced by a run. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 763c606f3..39ca5d2ab 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -608,8 +608,608 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- -## class `TelemetryConfig` -Telemetry configuration. +## class `RelayConfigPolicy` +NeMo Relay config validation policy. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayAtofEndpointConfig` +NeMo Relay ATOF remote endpoint configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayAtofConfig` +NeMo Relay ATOF export configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayS3StorageConfig` +NeMo Relay ATIF S3 storage configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayHttpStorageConfig` +NeMo Relay ATIF HTTP storage configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayAtifConfig` +NeMo Relay ATIF export configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayOtlpConfig` +NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayObservabilityConfig` +NeMo Relay observability component configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayComponentConfig` +Generic NeMo Relay plugin component configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `RelayConfig` +First-class NeMo Relay integration configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `TelemetryProviderConfig` +Provider-specific telemetry configuration. --- @@ -645,13 +1245,62 @@ Returns the set of fields that have been explicitly set on this model instance. --- -### method `disable` +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` ```python -disable() → Self +to_mapping() → dict[str, Any] ``` -Disable telemetry. +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `TelemetryConfig` +Telemetry configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + --- @@ -659,7 +1308,7 @@ Disable telemetry. ### method `enable_native` ```python -enable_native() → Self +enable_native(config: 'Mapping[str, Any] | None' = None) → Self ``` Let the selected adapter handle telemetry natively. @@ -670,11 +1319,7 @@ Let the selected adapter handle telemetry natively. ### method `enable_relay` ```python -enable_relay( - project: 'str | None' = None, - output_dir: 'str | Path | None' = None, - config: 'Mapping[str, Any] | None' = None -) → Self +enable_relay() → Self ``` Enable NeMo Relay telemetry for subsequently started runtimes. @@ -693,6 +1338,17 @@ Validate a mapping using this Pydantic model. --- +### method `remove_provider` + +```python +remove_provider(provider: "Literal['relay', 'native']") → Self +``` + +Remove a configured telemetry provider. + +--- + + ### method `to_mapping` ```python @@ -836,7 +1492,9 @@ Add a skill path and return this config. enable_relay( project: 'str | None' = None, output_dir: 'str | Path | None' = None, - config: 'Mapping[str, Any] | None' = None + observability: 'RelayObservabilityConfig | Mapping[str, Any] | None' = None, + components: 'Sequence[RelayComponentConfig | Mapping[str, Any]] | None' = None, + policy: 'RelayConfigPolicy | Mapping[str, Any] | None' = None ) → Self ``` diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx index 0e1c60848..41eea0cce 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 30 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx index dbbc0409c..76f2c1609 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Capability routing target." -position: 31 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx new file mode 100644 index 000000000..f8991a59c --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig.mdx @@ -0,0 +1,162 @@ +--- +title: "Enum Relay Atif Storage Config" +sidebar-title: "RelayAtifStorageConfig" +description: "Relay ATIF remote storage configuration." +position: 45 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
String,\n        headers: BTreeMap<String, String>,\n        header_env: BTreeMap<String, String>,\n        timeout_millis: u64,\n        extensions: BTreeMap<String, Value>,\n    },\n    S3 {\n        bucket: String,\n        key_prefix: Option<String>,\n        access_key_id: Option<String>,\n        secret_access_key_var: Option<String>,\n        session_token_var: Option<String>,\n        region: Option<String>,\n        endpoint_url: Option<String>,\n        allow_http: Option<bool>,\n        extensions: BTreeMap<String, Value>,\n    },\n}"}} />
+ +Relay ATIF remote storage configuration. + +## Variants + +### `Http` + +
+ +Upload ATIF artifacts to HTTP storage. + +#### Fields + +### `endpoint: String` + +HTTP storage endpoint. + +### `headers: BTreeMap` + +Static HTTP headers. + +### `header_env: BTreeMap` + +Environment-variable-backed HTTP headers. + +### `timeout_millis: u64` + +Request timeout in milliseconds. + +### `extensions: BTreeMap` + +Additive HTTP storage fields. + +### `S3` + +
+ +Upload ATIF artifacts to S3-compatible storage. + +#### Fields + +### `bucket: String` + +S3 bucket name. + +### `key_prefix: Option` + +Optional S3 object key prefix. + +### `access_key_id: Option` + +AWS access key id. + +### `secret_access_key_var: Option` + +Environment variable containing the AWS secret access key. + +### `session_token_var: Option` + +Environment variable containing the AWS session token. + +### `region: Option` + +AWS region. + +### `endpoint_url: Option` + +S3-compatible endpoint URL. + +### `allow_http: Option` + +Allow HTTP endpoints for S3-compatible storage. + +### `extensions: BTreeMap` + +Additive S3 storage fields. + +## Trait Implementations + +### `impl Clone for RelayAtifStorageConfig` + +
Clone for RelayAtifStorageConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtifStorageConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtifStorageConfig` + +
Debug for RelayAtifStorageConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtifStorageConfig` + +
Deserialize<'de> for RelayAtifStorageConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtifStorageConfig` + +
RelayAtifStorageConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtifStorageConfig` + +
PartialEq for RelayAtifStorageConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtifStorageConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtifStorageConfig` + +
Serialize for RelayAtifStorageConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayAtifStorageConfig` + +
StructuralPartialEq for RelayAtifStorageConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx new file mode 100644 index 000000000..fbfd3c737 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy.mdx @@ -0,0 +1,123 @@ +--- +title: "Enum Relay Atof Endpoint Field Name Policy" +sidebar-title: "RelayAtofEndpointFieldNamePolicy" +description: "Relay ATOF endpoint field-name policy." +position: 46 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +```rust +pub enum RelayAtofEndpointFieldNamePolicy { + Preserve, + ReplaceDots, +} +``` + +Relay ATOF endpoint field-name policy. + +## Variants + +### `Preserve` + +
+ +Preserve field names. + +### `ReplaceDots` + +
+ +Replace dots in field names. + +## Trait Implementations + +### `impl Clone for RelayAtofEndpointFieldNamePolicy` + +
Clone for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtofEndpointFieldNamePolicy` + +
Debug for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayAtofEndpointFieldNamePolicy` + +
Default for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `default` + +
default() -> RelayAtofEndpointFieldNamePolicy"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtofEndpointFieldNamePolicy` + +
Deserialize<'de> for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtofEndpointFieldNamePolicy` + +
RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtofEndpointFieldNamePolicy` + +
PartialEq for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtofEndpointFieldNamePolicy) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtofEndpointFieldNamePolicy` + +
Serialize for RelayAtofEndpointFieldNamePolicy"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayAtofEndpointFieldNamePolicy` + +
Copy for RelayAtofEndpointFieldNamePolicy"}} />
+ +### `impl Eq for RelayAtofEndpointFieldNamePolicy` + +
Eq for RelayAtofEndpointFieldNamePolicy"}} />
+ +### `impl StructuralPartialEq for RelayAtofEndpointFieldNamePolicy` + +
StructuralPartialEq for RelayAtofEndpointFieldNamePolicy"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx new file mode 100644 index 000000000..12ca939ed --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport.mdx @@ -0,0 +1,130 @@ +--- +title: "Enum Relay Atof Endpoint Transport" +sidebar-title: "RelayAtofEndpointTransport" +description: "Relay ATOF endpoint transport." +position: 47 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +```rust +pub enum RelayAtofEndpointTransport { + HttpPost, + Websocket, + Ndjson, +} +``` + +Relay ATOF endpoint transport. + +## Variants + +### `HttpPost` + +
+ +HTTP POST transport. + +### `Websocket` + +
+ +WebSocket transport. + +### `Ndjson` + +
+ +NDJSON transport. + +## Trait Implementations + +### `impl Clone for RelayAtofEndpointTransport` + +
Clone for RelayAtofEndpointTransport"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtofEndpointTransport"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtofEndpointTransport` + +
Debug for RelayAtofEndpointTransport"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayAtofEndpointTransport` + +
Default for RelayAtofEndpointTransport"}} />
+ +#### `default` + +
default() -> RelayAtofEndpointTransport"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtofEndpointTransport` + +
Deserialize<'de> for RelayAtofEndpointTransport"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtofEndpointTransport` + +
RelayAtofEndpointTransport"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtofEndpointTransport` + +
PartialEq for RelayAtofEndpointTransport"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtofEndpointTransport) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtofEndpointTransport` + +
Serialize for RelayAtofEndpointTransport"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayAtofEndpointTransport` + +
Copy for RelayAtofEndpointTransport"}} />
+ +### `impl Eq for RelayAtofEndpointTransport` + +
Eq for RelayAtofEndpointTransport"}} />
+ +### `impl StructuralPartialEq for RelayAtofEndpointTransport` + +
StructuralPartialEq for RelayAtofEndpointTransport"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx new file mode 100644 index 000000000..bb11f456b --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode.mdx @@ -0,0 +1,123 @@ +--- +title: "Enum Relay Atof Mode" +sidebar-title: "RelayAtofMode" +description: "Relay ATOF file mode." +position: 48 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +```rust +pub enum RelayAtofMode { + Append, + Overwrite, +} +``` + +Relay ATOF file mode. + +## Variants + +### `Append` + +
+ +Append to an existing ATOF file. + +### `Overwrite` + +
+ +Overwrite an existing ATOF file. + +## Trait Implementations + +### `impl Clone for RelayAtofMode` + +
Clone for RelayAtofMode"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtofMode"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtofMode` + +
Debug for RelayAtofMode"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayAtofMode` + +
Default for RelayAtofMode"}} />
+ +#### `default` + +
default() -> RelayAtofMode"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtofMode` + +
Deserialize<'de> for RelayAtofMode"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtofMode` + +
RelayAtofMode"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtofMode` + +
PartialEq for RelayAtofMode"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtofMode) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtofMode` + +
Serialize for RelayAtofMode"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayAtofMode` + +
Copy for RelayAtofMode"}} />
+ +### `impl Eq for RelayAtofMode` + +
Eq for RelayAtofMode"}} />
+ +### `impl StructuralPartialEq for RelayAtofMode` + +
StructuralPartialEq for RelayAtofMode"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx new file mode 100644 index 000000000..cf581a45f --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport.mdx @@ -0,0 +1,123 @@ +--- +title: "Enum Relay Otlp Transport" +sidebar-title: "RelayOtlpTransport" +description: "Relay OTLP transport." +position: 49 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +```rust +pub enum RelayOtlpTransport { + HttpBinary, + Grpc, +} +``` + +Relay OTLP transport. + +## Variants + +### `HttpBinary` + +
+ +OTLP HTTP binary transport. + +### `Grpc` + +
+ +OTLP gRPC transport. + +## Trait Implementations + +### `impl Clone for RelayOtlpTransport` + +
Clone for RelayOtlpTransport"}} />
+ +#### `clone` + +
clone(&self) -> RelayOtlpTransport"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayOtlpTransport` + +
Debug for RelayOtlpTransport"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayOtlpTransport` + +
Default for RelayOtlpTransport"}} />
+ +#### `default` + +
default() -> RelayOtlpTransport"}} />
+ +### `impl<'de> Deserialize<'de> for RelayOtlpTransport` + +
Deserialize<'de> for RelayOtlpTransport"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayOtlpTransport` + +
RelayOtlpTransport"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayOtlpTransport` + +
PartialEq for RelayOtlpTransport"}} />
+ +#### `eq` + +
eq(&self, other: &RelayOtlpTransport) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayOtlpTransport` + +
Serialize for RelayOtlpTransport"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayOtlpTransport` + +
Copy for RelayOtlpTransport"}} />
+ +### `impl Eq for RelayOtlpTransport` + +
Eq for RelayOtlpTransport"}} />
+ +### `impl StructuralPartialEq for RelayOtlpTransport` + +
StructuralPartialEq for RelayOtlpTransport"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx new file mode 100644 index 000000000..6b3d3dc09 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -0,0 +1,130 @@ +--- +title: "Enum Relay Unsupported Behavior" +sidebar-title: "RelayUnsupportedBehavior" +description: "Relay unsupported/unknown config handling." +position: 50 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +```rust +pub enum RelayUnsupportedBehavior { + Ignore, + Warn, + Error, +} +``` + +Relay unsupported/unknown config handling. + +## Variants + +### `Ignore` + +
+ +Ignore the unsupported or unknown value. + +### `Warn` + +
+ +Warn on the unsupported or unknown value. + +### `Error` + +
+ +Error on the unsupported or unknown value. + +## Trait Implementations + +### `impl Clone for RelayUnsupportedBehavior` + +
Clone for RelayUnsupportedBehavior"}} />
+ +#### `clone` + +
clone(&self) -> RelayUnsupportedBehavior"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayUnsupportedBehavior` + +
Debug for RelayUnsupportedBehavior"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayUnsupportedBehavior` + +
Default for RelayUnsupportedBehavior"}} />
+ +#### `default` + +
default() -> RelayUnsupportedBehavior"}} />
+ +### `impl<'de> Deserialize<'de> for RelayUnsupportedBehavior` + +
Deserialize<'de> for RelayUnsupportedBehavior"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayUnsupportedBehavior` + +
RelayUnsupportedBehavior"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayUnsupportedBehavior` + +
PartialEq for RelayUnsupportedBehavior"}} />
+ +#### `eq` + +
eq(&self, other: &RelayUnsupportedBehavior) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayUnsupportedBehavior` + +
Serialize for RelayUnsupportedBehavior"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RelayUnsupportedBehavior` + +
Copy for RelayUnsupportedBehavior"}} />
+ +### `impl Eq for RelayUnsupportedBehavior` + +
Eq for RelayUnsupportedBehavior"}} />
+ +### `impl StructuralPartialEq for RelayUnsupportedBehavior` + +
StructuralPartialEq for RelayUnsupportedBehavior"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/enum-telemetryprovider.mdx index 030ece7d7..27483ad33 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/enum-telemetryprovider.mdx @@ -102,6 +102,26 @@ Return the stable configuration value for this provider.
bool"}} />
+### `impl Ord for TelemetryProvider` + +
Ord for TelemetryProvider"}} />
+ +#### `cmp` + +
cmp(&self, other: &TelemetryProvider) -> Ordering"}} />
+ +#### `max` + +
max(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `min` + +
min(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `clamp` + +
clamp(self, min: Self, max: Self) -> Selfwhere\n    Self: Sized,"}} />
+ ### `impl PartialEq for TelemetryProvider`
PartialEq for TelemetryProvider"}} />
@@ -114,6 +134,30 @@ Return the stable configuration value for this provider.
ne(&self, other: &Rhs) -> bool"}} />
+### `impl PartialOrd for TelemetryProvider` + +
PartialOrd for TelemetryProvider"}} />
+ +#### `partial_cmp` + +
partial_cmp(&self, other: &TelemetryProvider) -> Option<Ordering>"}} />
+ +#### `lt` + +
lt(&self, other: &Rhs) -> bool"}} />
+ +#### `le` + +
le(&self, other: &Rhs) -> bool"}} />
+ +#### `gt` + +
gt(&self, other: &Rhs) -> bool"}} />
+ +#### `ge` + +
ge(&self, other: &Rhs) -> bool"}} />
+ ### `impl Serialize for TelemetryProvider`
Serialize for TelemetryProvider"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-adapter-descriptor.mdx index 382c5841a..2e49d35ff 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 33 +position: 34 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-fabric-document.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-fabric-document.mdx index ad45abcd7..bd93f4f8d 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-fabric-document.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-load-fabric-document.mdx @@ -2,7 +2,7 @@ title: "Function load_fabric_document" sidebar-title: "load_fabric_document" description: "Load a Fabric document from an agent directory or single agent config." -position: 34 +position: 35 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-from-config.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-from-config.mdx index fb8c731ea..9457fe190 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-from-config.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_effective_config_from_config" sidebar-title: "resolve_effective_config_from_config" description: "Resolve typed config/profile overlays into merged effective config." -position: 36 +position: 37 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-with-profiles.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-with-profiles.mdx index 2b2124314..1fed0c9e5 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-with-profiles.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config-with-profiles.mdx @@ -2,7 +2,7 @@ title: "Function resolve_effective_config_with_profiles" sidebar-title: "resolve_effective_config_with_profiles" description: "Resolve an agent directory or single agent config with ordered profiles into merged effective config." -position: 37 +position: 38 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config.mdx index 558cba6c9..f6338a28e 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-effective-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_effective_config" sidebar-title: "resolve_effective_config" description: "Resolve an agent directory or single agent config into merged effective config." -position: 35 +position: 36 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-config.mdx index eec4f2acf..72ccd8c13 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed Fabric config and typed profile overlays into a runnable plan." -position: 39 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-effective-config.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-effective-config.mdx index efaa9534c..124795331 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-effective-config.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-from-effective-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_effective_config" sidebar-title: "resolve_run_plan_from_effective_config" description: "Resolve execution planning metadata from merged effective config." -position: 40 +position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-with-profiles.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-with-profiles.mdx index ab313bb1e..7f5cdb105 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-with-profiles.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan-with-profiles.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_with_profiles" sidebar-title: "resolve_run_plan_with_profiles" description: "Resolve an agent directory or single agent config with ordered profile application." -position: 41 +position: 42 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan.mdx index 90951e465..af7b915b8 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-resolve-run-plan.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan" sidebar-title: "resolve_run_plan" description: "Resolve an agent directory or single agent config into a runnable plan." -position: 38 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/fn-validate-agent-directory.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/fn-validate-agent-directory.mdx index d4740ba0d..00347b51c 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/fn-validate-agent-directory.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/fn-validate-agent-directory.mdx @@ -2,7 +2,7 @@ title: "Function validate_agent_directory" sidebar-title: "validate_agent_directory" description: "Validate an agent directory or config, including discoverable profile YAMLs." -position: 42 +position: 43 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx index b175aebcb..b87c9ddbd 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "Fabric config models and loading helpers." -position: 74 +position: 75 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -32,6 +32,14 @@ Fabric config models and loading helpers. - [ModelConfig](/reference/api/rust-library-reference/fabric-core/config/struct-modelconfig): Model configuration. - [ProfileConfig](/reference/api/rust-library-reference/fabric-core/config/struct-profileconfig): Profile config applied on top of a Fabric config. - [ProfileRegistryConfig](/reference/api/rust-library-reference/fabric-core/config/struct-profileregistryconfig): Profile discovery config for curated package profiles. +- [RelayAtifConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayatifconfig): Relay ATIF export configuration. +- [RelayAtofConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayatofconfig): Relay ATOF export configuration. +- [RelayAtofEndpointConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayatofendpointconfig): Relay ATOF endpoint configuration. +- [RelayComponentConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relaycomponentconfig): Generic NeMo Relay plugin component configuration. +- [RelayConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayconfig): NeMo Relay integration configuration. +- [RelayConfigPolicy](/reference/api/rust-library-reference/fabric-core/config/struct-relayconfigpolicy): Relay validation policy. +- [RelayObservabilityConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayobservabilityconfig): NeMo Relay observability component configuration. +- [RelayOtlpConfig](/reference/api/rust-library-reference/fabric-core/config/struct-relayotlpconfig): Relay OpenTelemetry/OpenInference export configuration. - [ResolveContext](/reference/api/rust-library-reference/fabric-core/config/struct-resolvecontext): Source context used when resolving an in-memory Fabric config. - [ResolvedAdapterDescriptor](/reference/api/rust-library-reference/fabric-core/config/struct-resolvedadapterdescriptor): Adapter descriptor selected for a run plan. - [RunPlan](/reference/api/rust-library-reference/fabric-core/config/struct-runplan): Resolved Fabric run plan. @@ -40,6 +48,7 @@ Fabric config models and loading helpers. - [SkillConfig](/reference/api/rust-library-reference/fabric-core/config/struct-skillconfig): Skill capability configuration. - [TelemetryConfig](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig): Telemetry configuration. - [TelemetryPlan](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan): Resolved telemetry plan. +- [TelemetryProviderConfig](/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig): Provider-specific telemetry configuration. ## Enums @@ -51,6 +60,12 @@ Fabric config models and loading helpers. - [EnvironmentOwnership](/reference/api/rust-library-reference/fabric-core/config/enum-environmentownership): Whether Fabric owns the underlying environment resource. - [FabricDocument](/reference/api/rust-library-reference/fabric-core/config/enum-fabricdocument): A loaded Fabric document with resolved source path and agent root. - [McpExposure](/reference/api/rust-library-reference/fabric-core/config/enum-mcpexposure): MCP exposure strategy. +- [RelayAtifStorageConfig](/reference/api/rust-library-reference/fabric-core/config/enum-relayatifstorageconfig): Relay ATIF remote storage configuration. +- [RelayAtofEndpointFieldNamePolicy](/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointfieldnamepolicy): Relay ATOF endpoint field-name policy. +- [RelayAtofEndpointTransport](/reference/api/rust-library-reference/fabric-core/config/enum-relayatofendpointtransport): Relay ATOF endpoint transport. +- [RelayAtofMode](/reference/api/rust-library-reference/fabric-core/config/enum-relayatofmode): Relay ATOF file mode. +- [RelayOtlpTransport](/reference/api/rust-library-reference/fabric-core/config/enum-relayotlptransport): Relay OTLP transport. +- [RelayUnsupportedBehavior](/reference/api/rust-library-reference/fabric-core/config/enum-relayunsupportedbehavior): Relay unsupported/unknown config handling. - [ResolutionStrategy](/reference/api/rust-library-reference/fabric-core/config/enum-resolutionstrategy): Adapter install or availability strategy. - [TelemetryProvider](/reference/api/rust-library-reference/fabric-core/config/enum-telemetryprovider): Telemetry runtime provider. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx index 9a95ee871..5c0afd460 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-fabricconfig.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
String,\n    pub metadata: MetadataConfig,\n    pub harness: HarnessConfig,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<Value>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub profiles: ProfileRegistryConfig,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub metadata: MetadataConfig,\n    pub harness: HarnessConfig,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<Value>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub relay: Option<RelayConfig>,\n    pub profiles: ProfileRegistryConfig,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Versioned Fabric agent config. @@ -55,6 +55,10 @@ MCP capability configuration. Telemetry configuration. +### `relay: Option` + +First-class NeMo Relay integration configuration. + ### `profiles: ProfileRegistryConfig` Optional profile discovery config. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatifconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatifconfig.mdx new file mode 100644 index 000000000..c0d8e8dd4 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatifconfig.mdx @@ -0,0 +1,138 @@ +--- +title: "Struct Relay Atif Config" +sidebar-title: "RelayAtifConfig" +description: "Relay ATIF export configuration." +position: 20 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
bool,\n    pub agent_name: String,\n    pub agent_version: Option<String>,\n    pub model_name: String,\n    pub tool_definitions: Option<Vec<Value>>,\n    pub extra: Option<Value>,\n    pub output_directory: Option<PathBuf>,\n    pub filename_template: String,\n    pub storage: Option<Vec<RelayAtifStorageConfig>>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Relay ATIF export configuration. + +## Fields + +### `enabled: bool` + +Whether ATIF export is enabled. + +### `agent_name: String` + +Agent name written into ATIF. + +### `agent_version: Option` + +Agent version written into ATIF. + +### `model_name: String` + +Model name written into ATIF. + +### `tool_definitions: Option>` + +Tool definitions written into ATIF. + +### `extra: Option` + +Extra ATIF metadata. + +### `output_directory: Option` + +Directory used for ATIF files. + +### `filename_template: String` + +ATIF file name template. + +### `storage: Option>` + +Optional ATIF remote storage. + +### `extensions: BTreeMap` + +Additive ATIF fields. + +## Trait Implementations + +### `impl Clone for RelayAtifConfig` + +
Clone for RelayAtifConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtifConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtifConfig` + +
Debug for RelayAtifConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayAtifConfig` + +
Default for RelayAtifConfig"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtifConfig` + +
Deserialize<'de> for RelayAtifConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtifConfig` + +
RelayAtifConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtifConfig` + +
PartialEq for RelayAtifConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtifConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtifConfig` + +
Serialize for RelayAtifConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayAtifConfig` + +
StructuralPartialEq for RelayAtifConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofconfig.mdx new file mode 100644 index 000000000..4486582dc --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofconfig.mdx @@ -0,0 +1,122 @@ +--- +title: "Struct Relay Atof Config" +sidebar-title: "RelayAtofConfig" +description: "Relay ATOF export configuration." +position: 21 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
bool,\n    pub output_directory: Option<PathBuf>,\n    pub filename: Option<String>,\n    pub mode: RelayAtofMode,\n    pub endpoints: Vec<RelayAtofEndpointConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Relay ATOF export configuration. + +## Fields + +### `enabled: bool` + +Whether ATOF export is enabled. + +### `output_directory: Option` + +Directory used for ATOF files. + +### `filename: Option` + +ATOF file name. + +### `mode: RelayAtofMode` + +File write mode. + +### `endpoints: Vec` + +Optional remote ATOF endpoints. + +### `extensions: BTreeMap` + +Additive ATOF fields. + +## Trait Implementations + +### `impl Clone for RelayAtofConfig` + +
Clone for RelayAtofConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtofConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtofConfig` + +
Debug for RelayAtofConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayAtofConfig` + +
Default for RelayAtofConfig"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtofConfig` + +
Deserialize<'de> for RelayAtofConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtofConfig` + +
RelayAtofConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtofConfig` + +
PartialEq for RelayAtofConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtofConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtofConfig` + +
Serialize for RelayAtofConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayAtofConfig` + +
StructuralPartialEq for RelayAtofConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofendpointconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofendpointconfig.mdx new file mode 100644 index 000000000..77471a8c7 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayatofendpointconfig.mdx @@ -0,0 +1,114 @@ +--- +title: "Struct Relay Atof Endpoint Config" +sidebar-title: "RelayAtofEndpointConfig" +description: "Relay ATOF endpoint configuration." +position: 22 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
String,\n    pub transport: RelayAtofEndpointTransport,\n    pub headers: BTreeMap<String, String>,\n    pub timeout_millis: u64,\n    pub field_name_policy: RelayAtofEndpointFieldNamePolicy,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Relay ATOF endpoint configuration. + +## Fields + +### `url: String` + +Endpoint URL. + +### `transport: RelayAtofEndpointTransport` + +Endpoint transport. + +### `headers: BTreeMap` + +Endpoint headers. + +### `timeout_millis: u64` + +Request timeout in milliseconds. + +### `field_name_policy: RelayAtofEndpointFieldNamePolicy` + +Field-name handling policy. + +### `extensions: BTreeMap` + +Additive endpoint fields. + +## Trait Implementations + +### `impl Clone for RelayAtofEndpointConfig` + +
Clone for RelayAtofEndpointConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayAtofEndpointConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayAtofEndpointConfig` + +
Debug for RelayAtofEndpointConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RelayAtofEndpointConfig` + +
Deserialize<'de> for RelayAtofEndpointConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayAtofEndpointConfig` + +
RelayAtofEndpointConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayAtofEndpointConfig` + +
PartialEq for RelayAtofEndpointConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayAtofEndpointConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayAtofEndpointConfig` + +
Serialize for RelayAtofEndpointConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayAtofEndpointConfig` + +
StructuralPartialEq for RelayAtofEndpointConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relaycomponentconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relaycomponentconfig.mdx new file mode 100644 index 000000000..d07cd4327 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relaycomponentconfig.mdx @@ -0,0 +1,106 @@ +--- +title: "Struct Relay Component Config" +sidebar-title: "RelayComponentConfig" +description: "Generic NeMo Relay plugin component configuration." +position: 23 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
String,\n    pub enabled: bool,\n    pub config: BTreeMap<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Generic NeMo Relay plugin component configuration. + +## Fields + +### `kind: String` + +Registered Relay plugin kind. + +### `enabled: bool` + +Whether this Relay component should be activated. + +### `config: BTreeMap` + +Component-local Relay plugin config. + +### `extensions: BTreeMap` + +Additive component fields. + +## Trait Implementations + +### `impl Clone for RelayComponentConfig` + +
Clone for RelayComponentConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayComponentConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayComponentConfig` + +
Debug for RelayComponentConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RelayComponentConfig` + +
Deserialize<'de> for RelayComponentConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayComponentConfig` + +
RelayComponentConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayComponentConfig` + +
PartialEq for RelayComponentConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayComponentConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayComponentConfig` + +
Serialize for RelayComponentConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayComponentConfig` + +
StructuralPartialEq for RelayComponentConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfig.mdx new file mode 100644 index 000000000..87cdeb362 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfig.mdx @@ -0,0 +1,122 @@ +--- +title: "Struct Relay Config" +sidebar-title: "RelayConfig" +description: "NeMo Relay integration configuration." +position: 24 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
Option<String>,\n    pub output_dir: Option<PathBuf>,\n    pub observability: Option<RelayObservabilityConfig>,\n    pub components: Vec<RelayComponentConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +NeMo Relay integration configuration. + +## Fields + +### `project: Option` + +Optional project name for Relay backends. + +### `output_dir: Option` + +Optional Relay output directory. + +### `observability: Option` + +Relay observability component configuration. + +### `components: Vec` + +Additional Relay plugin components. + +### `policy: Option` + +Relay plugin validation policy. + +### `extensions: BTreeMap` + +Additive Relay fields. + +## Trait Implementations + +### `impl Clone for RelayConfig` + +
Clone for RelayConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayConfig` + +
Debug for RelayConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayConfig` + +
Default for RelayConfig"}} />
+ +#### `default` + +
default() -> RelayConfig"}} />
+ +### `impl<'de> Deserialize<'de> for RelayConfig` + +
Deserialize<'de> for RelayConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayConfig` + +
RelayConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayConfig` + +
PartialEq for RelayConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayConfig` + +
Serialize for RelayConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayConfig` + +
StructuralPartialEq for RelayConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfigpolicy.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfigpolicy.mdx new file mode 100644 index 000000000..a6e3f4e86 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayconfigpolicy.mdx @@ -0,0 +1,110 @@ +--- +title: "Struct Relay Config Policy" +sidebar-title: "RelayConfigPolicy" +description: "Relay validation policy." +position: 25 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
RelayUnsupportedBehavior,\n    pub unknown_field: RelayUnsupportedBehavior,\n    pub unsupported_value: RelayUnsupportedBehavior,\n}"}} />
+ +Relay validation policy. + +## Fields + +### `unknown_component: RelayUnsupportedBehavior` + +Policy for unknown components. + +### `unknown_field: RelayUnsupportedBehavior` + +Policy for unknown fields. + +### `unsupported_value: RelayUnsupportedBehavior` + +Policy for unsupported values. + +## Trait Implementations + +### `impl Clone for RelayConfigPolicy` + +
Clone for RelayConfigPolicy"}} />
+ +#### `clone` + +
clone(&self) -> RelayConfigPolicy"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayConfigPolicy` + +
Debug for RelayConfigPolicy"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayConfigPolicy` + +
Default for RelayConfigPolicy"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for RelayConfigPolicy` + +
Deserialize<'de> for RelayConfigPolicy"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayConfigPolicy` + +
RelayConfigPolicy"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayConfigPolicy` + +
PartialEq for RelayConfigPolicy"}} />
+ +#### `eq` + +
eq(&self, other: &RelayConfigPolicy) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayConfigPolicy` + +
Serialize for RelayConfigPolicy"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayConfigPolicy` + +
StructuralPartialEq for RelayConfigPolicy"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayobservabilityconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayobservabilityconfig.mdx new file mode 100644 index 000000000..e929a6e76 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayobservabilityconfig.mdx @@ -0,0 +1,126 @@ +--- +title: "Struct Relay Observability Config" +sidebar-title: "RelayObservabilityConfig" +description: "NeMo Relay observability component configuration." +position: 26 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
u32,\n    pub atof: Option<RelayAtofConfig>,\n    pub atif: Option<RelayAtifConfig>,\n    pub opentelemetry: Option<RelayOtlpConfig>,\n    pub openinference: Option<RelayOtlpConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +NeMo Relay observability component configuration. + +## Fields + +### `version: u32` + +Relay observability config version. + +### `atof: Option` + +ATOF export configuration. + +### `atif: Option` + +ATIF export configuration. + +### `opentelemetry: Option` + +OpenTelemetry export configuration. + +### `openinference: Option` + +OpenInference export configuration. + +### `policy: Option` + +Relay config validation policy. + +### `extensions: BTreeMap` + +Additive observability fields. + +## Trait Implementations + +### `impl Clone for RelayObservabilityConfig` + +
Clone for RelayObservabilityConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayObservabilityConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayObservabilityConfig` + +
Debug for RelayObservabilityConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayObservabilityConfig` + +
Default for RelayObservabilityConfig"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for RelayObservabilityConfig` + +
Deserialize<'de> for RelayObservabilityConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayObservabilityConfig` + +
RelayObservabilityConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayObservabilityConfig` + +
PartialEq for RelayObservabilityConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayObservabilityConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayObservabilityConfig` + +
Serialize for RelayObservabilityConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayObservabilityConfig` + +
StructuralPartialEq for RelayObservabilityConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayotlpconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayotlpconfig.mdx new file mode 100644 index 000000000..71aaa9860 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-relayotlpconfig.mdx @@ -0,0 +1,142 @@ +--- +title: "Struct Relay Otlp Config" +sidebar-title: "RelayOtlpConfig" +description: "Relay OpenTelemetry/OpenInference export configuration." +position: 27 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
bool,\n    pub transport: RelayOtlpTransport,\n    pub endpoint: Option<String>,\n    pub headers: BTreeMap<String, String>,\n    pub resource_attributes: BTreeMap<String, String>,\n    pub service_name: String,\n    pub service_namespace: Option<String>,\n    pub service_version: Option<String>,\n    pub instrumentation_scope: Option<String>,\n    pub timeout_millis: u64,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Relay OpenTelemetry/OpenInference export configuration. + +## Fields + +### `enabled: bool` + +Whether OTLP export is enabled. + +### `transport: RelayOtlpTransport` + +OTLP transport. + +### `endpoint: Option` + +OTLP endpoint. + +### `headers: BTreeMap` + +OTLP headers. + +### `resource_attributes: BTreeMap` + +OTLP resource attributes. + +### `service_name: String` + +OTLP service name. + +### `service_namespace: Option` + +OTLP service namespace. + +### `service_version: Option` + +OTLP service version. + +### `instrumentation_scope: Option` + +OTLP instrumentation scope. + +### `timeout_millis: u64` + +Request timeout in milliseconds. + +### `extensions: BTreeMap` + +Additive OTLP fields. + +## Trait Implementations + +### `impl Clone for RelayOtlpConfig` + +
Clone for RelayOtlpConfig"}} />
+ +#### `clone` + +
clone(&self) -> RelayOtlpConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RelayOtlpConfig` + +
Debug for RelayOtlpConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RelayOtlpConfig` + +
Default for RelayOtlpConfig"}} />
+ +#### `default` + +
default() -> Self"}} />
+ +### `impl<'de> Deserialize<'de> for RelayOtlpConfig` + +
Deserialize<'de> for RelayOtlpConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RelayOtlpConfig` + +
RelayOtlpConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RelayOtlpConfig` + +
PartialEq for RelayOtlpConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RelayOtlpConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RelayOtlpConfig` + +
Serialize for RelayOtlpConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for RelayOtlpConfig` + +
StructuralPartialEq for RelayOtlpConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig.mdx index 0857073e1..7d201c34e 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryconfig.mdx @@ -9,31 +9,15 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
bool,\n    pub provider: TelemetryProvider,\n    pub project: Option<String>,\n    pub output_dir: Option<PathBuf>,\n    pub config: Option<Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
BTreeMap<TelemetryProvider, TelemetryProviderConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Telemetry configuration. ## Fields -### `enabled: bool` +### `providers: BTreeMap` -Whether telemetry is enabled for this run. - -### `provider: TelemetryProvider` - -Telemetry provider responsible for runtime integration. - -### `project: Option` - -Optional project name for telemetry backends. - -### `output_dir: Option` - -Optional telemetry output directory. - -### `config: Option` - -Pass-through telemetry backend config. +Telemetry providers enabled for this run. ### `extensions: BTreeMap` diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan.mdx index ed5349d70..97ca74a41 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryplan.mdx @@ -9,15 +9,15 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
TelemetryProvider,\n    pub relay_enabled: bool,\n    pub relay_project: Option<String>,\n    pub relay_output_dir: Option<PathBuf>,\n    pub relay_config: Option<Value>,\n    pub adapter_outputs: Vec<String>,\n}"}} />
+
Vec<TelemetryProvider>,\n    pub relay_enabled: bool,\n    pub relay_project: Option<String>,\n    pub relay_output_dir: Option<PathBuf>,\n    pub relay_config: Option<Value>,\n    pub native_config: Option<Value>,\n    pub adapter_outputs: Vec<String>,\n}"}} />
Resolved telemetry plan. ## Fields -### `provider: TelemetryProvider` +### `providers: Vec` -Telemetry provider selected for this run. +Telemetry providers selected for this run. ### `relay_enabled: bool` @@ -35,6 +35,10 @@ Relay output directory, when configured. Relay pass-through config. +### `native_config: Option` + +Native telemetry pass-through config. + ### `adapter_outputs: Vec` Telemetry outputs declared by the selected adapter descriptor. diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig.mdx new file mode 100644 index 000000000..6081568d0 --- /dev/null +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-telemetryproviderconfig.mdx @@ -0,0 +1,106 @@ +--- +title: "Struct Telemetry Provider Config" +sidebar-title: "TelemetryProviderConfig" +description: "Provider-specific telemetry configuration." +position: 33 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p fabric-core`. + +
Option<Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Provider-specific telemetry configuration. + +## Fields + +### `config: Option` + +Provider-specific pass-through config. + +### `extensions: BTreeMap` + +Additive provider fields. + +## Trait Implementations + +### `impl Clone for TelemetryProviderConfig` + +
Clone for TelemetryProviderConfig"}} />
+ +#### `clone` + +
clone(&self) -> TelemetryProviderConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TelemetryProviderConfig` + +
Debug for TelemetryProviderConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for TelemetryProviderConfig` + +
Default for TelemetryProviderConfig"}} />
+ +#### `default` + +
default() -> TelemetryProviderConfig"}} />
+ +### `impl<'de> Deserialize<'de> for TelemetryProviderConfig` + +
Deserialize<'de> for TelemetryProviderConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TelemetryProviderConfig` + +
TelemetryProviderConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TelemetryProviderConfig` + +
PartialEq for TelemetryProviderConfig"}} />
+ +#### `eq` + +
eq(&self, other: &TelemetryProviderConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TelemetryProviderConfig` + +
Serialize for TelemetryProviderConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for TelemetryProviderConfig` + +
StructuralPartialEq for TelemetryProviderConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/fabric-core/doctor/enum-doctorstatus.mdx index bd2ac54c7..5ef34bb27 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 45 +position: 46 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/fabric-core/doctor/fn-doctor-plan.mdx index 1e00456ba..733398689 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 46 +position: 47 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/doctor/index.mdx index 522bac0f8..e79ae26a8 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for Fabric." -position: 75 +position: 76 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorcheck.mdx index 5e97be328..8e94ca38d 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 43 +position: 44 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorreport.mdx index 372c5faa0..4d6c153d2 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 44 +position: 45 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx index f9a7c83ac..d53f38e54 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by Fabric config loading and validation." -position: 47 +position: 48 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/error/index.mdx index 151328eb8..dfb44afde 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for Fabric core." -position: 76 +position: 77 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/fabric-core/error/type-result.mdx index 508833955..6c600f286 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core Fabric result type." -position: 48 +position: 49 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/fabric-core/fn-version.mdx index e98d68d11..dd81b4a7c 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 79 +position: 80 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/index.mdx index cf59388d5..6acfef10b 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/index.mdx @@ -45,6 +45,7 @@ Core config and runtime contract for NeMo Fabric. - `pub use config::TelemetryConfig;` - `pub use config::TelemetryPlan;` - `pub use config::TelemetryProvider;` +- `pub use config::TelemetryProviderConfig;` - `pub use config::load_adapter_descriptor;` - `pub use config::load_fabric_document;` - `pub use config::resolve_effective_config;` diff --git a/docs/reference/api/rust-library-reference/fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/runtime/index.mdx index a35d051a6..ab02904ca 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 77 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/fabric-core/schema/index.mdx index 38faab82d..6a09ead74 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public Fabric contract." -position: 78 +position: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index ad9fe4395..80f0cc8af 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -149,6 +149,8 @@ capability_config.enable_relay( Config helpers edit the typed config before planning or starting a runtime. They do not modify already-started runtimes. Use `remove_mcp_server(name)` and `remove_skill_path(path)` to remove capabilities from a copied config. +Telemetry is enabled by adding entries to `telemetry.providers`; Relay-specific +settings live in the top-level `relay` block. For evaluation or deployment variations, use ordinary Python functions and copies of the typed config. Supply the final config to Fabric when the variation @@ -171,6 +173,23 @@ def review_agent_config(base, *, github_mcp: bool, relay: bool): variant = review_agent_config(config, github_mcp=True, relay=True) ``` +Relay observability is represented directly in the SDK config's top-level +`relay` block, and additional Relay plugin components can be supplied +generically when their component package is available in the runtime +environment: + +```python +from nemo_fabric import RelayComponentConfig + +relay_config = config.model_copy(deep=True) +relay_config.enable_relay( + output_dir="./artifacts/relay", + components=[ + RelayComponentConfig(kind="switchyard", config={"route": "canary"}), + ], +) +``` + The repository's [code-review example](https://github.com/NVIDIA/NeMo-Fabric/tree/main/examples/code_review_agent) uses this pattern for complete Hermes SDK, Hermes CLI, Codex CLI, Deep Agents, diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index dc03fdd7a..dfddf783c 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -7,15 +7,17 @@ from pathlib import Path -from nemo_fabric import ( - EnvironmentConfig, - FabricConfig, - HarnessConfig, - MetadataConfig, - ModelConfig, - RuntimeConfig, - TelemetryConfig, -) +from nemo_fabric import EnvironmentConfig +from nemo_fabric import FabricConfig +from nemo_fabric import HarnessConfig +from nemo_fabric import MetadataConfig +from nemo_fabric import ModelConfig +from nemo_fabric import RelayAtifConfig +from nemo_fabric import RelayAtofConfig +from nemo_fabric import RelayObservabilityConfig +from nemo_fabric import RelayOtlpConfig +from nemo_fabric import RuntimeConfig +from nemo_fabric import TelemetryConfig BASE_DIR = Path(__file__).resolve().parent WORKSPACE = "./repos/my-service" @@ -53,7 +55,7 @@ def base_config() -> FabricConfig: workspace=WORKSPACE, artifacts="./artifacts/local", ), - telemetry=TelemetryConfig(enabled=False), + telemetry=TelemetryConfig(), ) config.add_skill_path(SKILL_PATH) config.add_mcp_server( @@ -139,9 +141,7 @@ def codex_cli_config() -> FabricConfig: "config_overrides": {"model_reasoning_effort": "high"}, }, ) - config.models = { - "default": ModelConfig(provider="openai", model="openai/gpt-5.4") - } + config.models = {"default": ModelConfig(provider="openai", model="openai/gpt-5.4")} config.runtime = RuntimeConfig( input_schema="text", output_schema="message", @@ -220,31 +220,21 @@ def with_relay(base: FabricConfig) -> FabricConfig: config = base.model_copy(deep=True) config.enable_relay( output_dir="./artifacts/relay", - config={ - "version": 1, - "components": [ - { - "kind": "observability", - "enabled": True, - "config": { - "version": 1, - "atif": { - "enabled": True, - "output_directory": "./artifacts/relay", - "filename_template": "trajectory-{session_id}.atif.json", - "agent_name": "code-review-agent", - "agent_version": "fabric-sdk-example", - }, - "atof": { - "enabled": True, - "output_directory": "./artifacts/relay", - "filename": "events.atof.jsonl", - "mode": "overwrite", - }, - }, - } - ], - }, + observability=RelayObservabilityConfig( + atif=RelayAtifConfig( + enabled=True, + output_directory="./artifacts/relay", + filename_template="trajectory-{session_id}.atif.json", + agent_name="code-review-agent", + agent_version="fabric-sdk-example", + ), + atof=RelayAtofConfig( + enabled=True, + output_directory="./artifacts/relay", + filename="events.atof.jsonl", + mode="overwrite", + ), + ), ) return config @@ -255,29 +245,19 @@ def with_relay_otel(base: FabricConfig) -> FabricConfig: config = base.model_copy(deep=True) config.enable_relay( output_dir="./artifacts/relay-otel", - config={ - "version": 1, - "components": [ - { - "kind": "observability", - "enabled": True, - "config": { - "version": 1, - "opentelemetry": { - "enabled": True, - "transport": "http_binary", - "endpoint": "http://localhost:4318/v1/traces", - "service_name": "code-review-agent", - "service_namespace": "fabric", - "service_version": "fabric-sdk-example", - "instrumentation_scope": "nemo-relay-otel", - "timeout_millis": 3000, - "resource_attributes": {"deployment.environment": "dev"}, - }, - }, - } - ], - }, + observability=RelayObservabilityConfig( + opentelemetry=RelayOtlpConfig( + enabled=True, + transport="http_binary", + endpoint="http://localhost:4318/v1/traces", + service_name="code-review-agent", + service_namespace="fabric", + service_version="fabric-sdk-example", + instrumentation_scope="nemo-relay-otel", + timeout_millis=3000, + resource_attributes={"deployment.environment": "dev"}, + ), + ), ) return config @@ -286,16 +266,24 @@ def with_relay_openinference(base: FabricConfig) -> FabricConfig: """Return a copy with Relay OpenInference export enabled.""" config = with_relay(base) - assert config.telemetry is not None and config.telemetry.config is not None - component = config.telemetry.config["components"][0]["config"] - component["openinference"] = { - "enabled": True, - "transport": "http_binary", - "endpoint": "http://localhost:6006/v1/traces", - } - config.telemetry.output_dir = "./artifacts/relay-openinference" - component["atif"]["output_directory"] = "./artifacts/relay-openinference" - component["atof"]["output_directory"] = "./artifacts/relay-openinference" + assert config.telemetry is not None + assert config.relay is not None + relay = config.relay + assert not isinstance(relay, dict) + assert relay.observability is not None + observability = relay.observability + assert not isinstance(observability, dict) + + relay.output_dir = "./artifacts/relay-openinference" + observability.openinference = RelayOtlpConfig( + enabled=True, + transport="http_binary", + endpoint="http://localhost:6006/v1/traces", + ) + if isinstance(observability.atif, RelayAtifConfig): + observability.atif.output_directory = "./artifacts/relay-openinference" + if isinstance(observability.atof, RelayAtofConfig): + observability.atof.output_directory = "./artifacts/relay-openinference" return config @@ -304,25 +292,27 @@ def with_native_otel(base: FabricConfig) -> FabricConfig: config = base.model_copy(deep=True) config.telemetry = TelemetryConfig( - enabled=True, - provider="native", - config={ - "version": 1, - "components": [ - { - "kind": "observability", - "enabled": True, - "config": { - "version": 1, - "opentelemetry": { + providers={ + "native": { + "config": { + "version": 1, + "components": [ + { + "kind": "observability", "enabled": True, - "transport": "http_binary", - "endpoint": "http://localhost:4318/v1/traces", - "resource_attributes": {"deployment.environment": "dev"}, + "config": { + "version": 1, + "opentelemetry": { + "enabled": True, + "transport": "http_binary", + "endpoint": "http://localhost:4318/v1/traces", + "resource_attributes": {"deployment.environment": "dev"}, + }, + }, }, - }, - } - ], + ], + }, + } }, ) return config diff --git a/examples/harbor/README.md b/examples/harbor/README.md index 7a03cb264..a4f58440d 100644 --- a/examples/harbor/README.md +++ b/examples/harbor/README.md @@ -59,7 +59,10 @@ task image; the [multi-harness demo](demo/README.md) shows one complete setup. ## Prepare a Fabric config -Create one complete config for the execution path. For example: +Create one complete config for the execution path. Harbor runs one Fabric +harness at a time; to switch from Hermes CLI to Hermes SDK, Codex CLI, or a +Relay-enabled variant, pass a different complete config through +`fabric_config_path`. For example: ```yaml schema_version: fabric.agent/v1alpha1 @@ -89,14 +92,43 @@ environment: workspace: /app artifacts: /logs/agent/fabric-artifacts -telemetry: - enabled: false +# Omit telemetry when no telemetry provider is required. ``` Copy the config into the task image, for example at `/opt/fabric/configs/hermes.yaml`. `fabric_config_path` always refers to the path inside the task environment, not the host checkout. +Relay integration settings live in the first-class top-level `relay` block: + +```yaml +telemetry: + providers: + relay: {} + +relay: + output_dir: /logs/agent/fabric-artifacts/relay + observability: + atif: + enabled: true + output_directory: /logs/agent/fabric-artifacts/relay + atof: + enabled: true + output_directory: /logs/agent/fabric-artifacts/relay + filename: events.atof.jsonl + mode: overwrite +``` + +Use `relay.components` for additional Relay plugin components such as Switchyard: + +```yaml +relay: + components: + - kind: switchyard + config: + route: canary +``` + ## Run the task Pass one complete Fabric config path through Harbor's agent arguments: diff --git a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml index 527f0746b..40d44b875 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/codex.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/codex.yaml @@ -32,5 +32,4 @@ environment: workspace: /app artifacts: /logs/agent/fabric-artifacts/codex -telemetry: - enabled: false +telemetry: null diff --git a/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml b/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml index 0cef67d18..919df6f3a 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml @@ -34,27 +34,23 @@ environment: artifacts: /logs/agent/fabric-artifacts/hermes-relay telemetry: - enabled: true - provider: relay + providers: + relay: {} + +relay: output_dir: /logs/agent/fabric-artifacts/hermes-relay/relay - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - atif: - enabled: true - output_directory: /logs/agent/fabric-artifacts/hermes-relay/relay - filename_template: trajectory-{session_id}.atif.json - agent_name: harbor-calculator-demo - atof: - enabled: true - output_directory: /logs/agent/fabric-artifacts/hermes-relay/relay - filename: events.atof.jsonl - mode: overwrite - openinference: - enabled: true - transport: http_binary - endpoint: http://host.docker.internal:6006/v1/traces + observability: + atif: + enabled: true + output_directory: /logs/agent/fabric-artifacts/hermes-relay/relay + filename_template: trajectory-{session_id}.atif.json + agent_name: harbor-calculator-demo + atof: + enabled: true + output_directory: /logs/agent/fabric-artifacts/hermes-relay/relay + filename: events.atof.jsonl + mode: overwrite + openinference: + enabled: true + transport: http_binary + endpoint: http://host.docker.internal:6006/v1/traces diff --git a/examples/harbor/demo/task/environment/fabric/configs/hermes.yaml b/examples/harbor/demo/task/environment/fabric/configs/hermes.yaml index 2aec928a1..7984cd3c5 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/hermes.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/hermes.yaml @@ -33,5 +33,4 @@ environment: workspace: /app artifacts: /logs/agent/fabric-artifacts/hermes -telemetry: - enabled: false +telemetry: null diff --git a/examples/harbor/demo/task/environment/fabric/configs/smoke.yaml b/examples/harbor/demo/task/environment/fabric/configs/smoke.yaml index aacdfc865..bf0aec56c 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/smoke.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/smoke.yaml @@ -21,5 +21,4 @@ environment: workspace: /app artifacts: /logs/agent/fabric-artifacts/smoke -telemetry: - enabled: false +telemetry: null diff --git a/pyproject.toml b/pyproject.toml index 07dddf640..f5af1218a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ hermes = [ ] relay = [ - "nemo-relay~=0.4", + "nemo-relay~=0.5.0", "tomli-w~=1.2", # Needed by adapters to write relay config files ] diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 2af20b50a..356cf6fb0 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -4,47 +4,53 @@ """Python SDK surface for NeMo Fabric.""" from nemo_fabric.client import Fabric -from nemo_fabric.errors import ( - FabricCapabilityError, - FabricConfigError, - FabricError, - FabricNativeUnavailableError, - FabricRuntimeError, - FabricStateError, -) -from nemo_fabric.runtime import Runtime, RuntimeStatus -from nemo_fabric.models import ( - EnvironmentConfig, - FabricBaseModel, - FabricConfig, - FabricProfileConfig, - HarnessConfig, - McpConfig, - McpServerConfig, - MetadataConfig, - ModelConfig, - ProfileRegistryConfig, - RunRequest, - RuntimeConfig, - SkillConfig, - TelemetryConfig, -) -from nemo_fabric.types import ( - AdapterInfo, - ArtifactManifest, - ArtifactRef, - DoctorCheck, - DoctorReport, - EffectiveConfig, - ErrorInfo, - FabricEvent, - RunOutput, - RunPlan, - RunResult, - RuntimeCapabilities, - RuntimeHandle, - TelemetryRef, -) +from nemo_fabric.errors import FabricCapabilityError +from nemo_fabric.errors import FabricConfigError +from nemo_fabric.errors import FabricError +from nemo_fabric.errors import FabricNativeUnavailableError +from nemo_fabric.errors import FabricRuntimeError +from nemo_fabric.errors import FabricStateError +from nemo_fabric.models import EnvironmentConfig +from nemo_fabric.models import FabricBaseModel +from nemo_fabric.models import FabricConfig +from nemo_fabric.models import FabricProfileConfig +from nemo_fabric.models import HarnessConfig +from nemo_fabric.models import McpConfig +from nemo_fabric.models import McpServerConfig +from nemo_fabric.models import MetadataConfig +from nemo_fabric.models import ModelConfig +from nemo_fabric.models import ProfileRegistryConfig +from nemo_fabric.models import RelayAtifConfig +from nemo_fabric.models import RelayAtofConfig +from nemo_fabric.models import RelayAtofEndpointConfig +from nemo_fabric.models import RelayComponentConfig +from nemo_fabric.models import RelayConfig +from nemo_fabric.models import RelayConfigPolicy +from nemo_fabric.models import RelayHttpStorageConfig +from nemo_fabric.models import RelayObservabilityConfig +from nemo_fabric.models import RelayOtlpConfig +from nemo_fabric.models import RelayS3StorageConfig +from nemo_fabric.models import RunRequest +from nemo_fabric.models import RuntimeConfig +from nemo_fabric.models import SkillConfig +from nemo_fabric.models import TelemetryConfig +from nemo_fabric.models import TelemetryProviderConfig +from nemo_fabric.runtime import Runtime +from nemo_fabric.runtime import RuntimeStatus +from nemo_fabric.types import AdapterInfo +from nemo_fabric.types import ArtifactManifest +from nemo_fabric.types import ArtifactRef +from nemo_fabric.types import DoctorCheck +from nemo_fabric.types import DoctorReport +from nemo_fabric.types import EffectiveConfig +from nemo_fabric.types import ErrorInfo +from nemo_fabric.types import FabricEvent +from nemo_fabric.types import RunOutput +from nemo_fabric.types import RunPlan +from nemo_fabric.types import RunResult +from nemo_fabric.types import RuntimeCapabilities +from nemo_fabric.types import RuntimeHandle +from nemo_fabric.types import TelemetryRef __all__ = [ "AdapterInfo", @@ -69,6 +75,16 @@ "MetadataConfig", "ModelConfig", "ProfileRegistryConfig", + "RelayAtifConfig", + "RelayAtofConfig", + "RelayAtofEndpointConfig", + "RelayComponentConfig", + "RelayConfigPolicy", + "RelayHttpStorageConfig", + "RelayObservabilityConfig", + "RelayOtlpConfig", + "RelayS3StorageConfig", + "RelayConfig", "FabricNativeUnavailableError", "FabricRuntimeError", "FabricStateError", @@ -83,5 +99,6 @@ "RuntimeStatus", "SkillConfig", "TelemetryConfig", + "TelemetryProviderConfig", "TelemetryRef", ] diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index cc3fc38b5..098851634 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -13,12 +13,19 @@ import math import uuid -from collections.abc import Mapping, Sequence +from collections.abc import Mapping +from collections.abc import Sequence from pathlib import Path -from typing import Any, Literal +from typing import Annotated +from typing import Any +from typing import Literal +from typing import Self -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from typing_extensions import Self +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_validator +from pydantic import model_validator def _json_value(value: Any, name: str) -> Any: @@ -225,45 +232,159 @@ def remove_server(self, name: str) -> Self: return self -class TelemetryConfig(FabricBaseModel): - """Telemetry configuration.""" +class RelayConfigPolicy(FabricBaseModel): + """NeMo Relay config validation policy.""" + + unknown_component: Literal["ignore", "warn", "error"] = "warn" + unknown_field: Literal["ignore", "warn", "error"] = "warn" + unsupported_value: Literal["ignore", "warn", "error"] = "error" + + +class RelayAtofEndpointConfig(FabricBaseModel): + """NeMo Relay ATOF remote endpoint configuration.""" + + url: str + transport: Literal["http_post", "websocket", "ndjson"] = "http_post" + headers: dict[str, str] = Field(default_factory=dict) + timeout_millis: int = 3000 + field_name_policy: Literal["preserve", "replace_dots"] = "preserve" + + +class RelayAtofConfig(FabricBaseModel): + """NeMo Relay ATOF export configuration.""" + + enabled: bool = False + output_directory: str | Path | None = None + filename: str | None = None + mode: Literal["append", "overwrite"] = "append" + endpoints: list[RelayAtofEndpointConfig | dict[str, Any]] | None = None + + +class RelayS3StorageConfig(FabricBaseModel): + """NeMo Relay ATIF S3 storage configuration.""" + + type: Literal["s3"] = "s3" + bucket: str = "" + key_prefix: str | None = None + access_key_id: str | None = None + secret_access_key_var: str | None = None + session_token_var: str | None = None + region: str | None = None + endpoint_url: str | None = None + allow_http: bool | None = None + + +class RelayHttpStorageConfig(FabricBaseModel): + """NeMo Relay ATIF HTTP storage configuration.""" + + type: Literal["http"] = "http" + endpoint: str = "" + headers: dict[str, str] = Field(default_factory=dict) + header_env: dict[str, str] = Field(default_factory=dict) + timeout_millis: int = 3000 + + +class RelayAtifConfig(FabricBaseModel): + """NeMo Relay ATIF export configuration.""" + + enabled: bool = False + agent_name: str = "NeMo Relay" + agent_version: str | None = None + model_name: str = "unknown" + tool_definitions: list[dict[str, Any]] | None = None + extra: dict[str, Any] | None = None + output_directory: str | Path | None = None + filename_template: str = "nemo-relay-atif-{session_id}.json" + storage: ( + list[ + Annotated[ + RelayS3StorageConfig | RelayHttpStorageConfig, + Field(discriminator="type"), + ] + | dict[str, Any] + ] + | None + ) = None + + +class RelayOtlpConfig(FabricBaseModel): + """NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference.""" enabled: bool = False - provider: Literal["relay", "native"] | None = None + transport: Literal["http_binary", "grpc"] = "http_binary" + endpoint: str | None = None + headers: dict[str, str] = Field(default_factory=dict) + resource_attributes: dict[str, str] = Field(default_factory=dict) + service_name: str = "nemo-relay" + service_namespace: str | None = None + service_version: str | None = None + instrumentation_scope: str | None = None + timeout_millis: int = 3000 + + +class RelayObservabilityConfig(FabricBaseModel): + """NeMo Relay observability component configuration.""" + + version: int = 1 + atof: RelayAtofConfig | dict[str, Any] | None = None + atif: RelayAtifConfig | dict[str, Any] | None = None + opentelemetry: RelayOtlpConfig | dict[str, Any] | None = None + openinference: RelayOtlpConfig | dict[str, Any] | None = None + policy: RelayConfigPolicy | dict[str, Any] | None = None + + +class RelayComponentConfig(FabricBaseModel): + """Generic NeMo Relay plugin component configuration.""" + + kind: str = Field(min_length=1) + enabled: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + +class RelayConfig(FabricBaseModel): + """First-class NeMo Relay integration configuration.""" + project: str | None = None output_dir: str | Path | None = None + observability: RelayObservabilityConfig | dict[str, Any] | None = None + components: list[RelayComponentConfig | dict[str, Any]] = Field(default_factory=list) + policy: RelayConfigPolicy | dict[str, Any] | None = None + + +class TelemetryProviderConfig(FabricBaseModel): + """Provider-specific telemetry configuration.""" + config: dict[str, Any] | None = None + +class TelemetryConfig(FabricBaseModel): + """Telemetry configuration.""" + + providers: dict[Literal["relay", "native"], TelemetryProviderConfig | dict[str, Any]] = Field(default_factory=dict) + def enable_relay( self, - *, - project: str | None = None, - output_dir: str | Path | None = None, - config: Mapping[str, Any] | None = None, ) -> Self: """Enable NeMo Relay telemetry for subsequently started runtimes.""" - self.enabled = True - self.provider = "relay" - if project is not None: - self.project = project - if output_dir is not None: - self.output_dir = output_dir - if config is not None: - self.config = dict(config) + self.providers["relay"] = TelemetryProviderConfig() return self - def enable_native(self) -> Self: + def enable_native(self, *, config: Mapping[str, Any] | None = None) -> Self: """Let the selected adapter handle telemetry natively.""" - self.enabled = True - self.provider = "native" + provider_config = self.providers.get("native", TelemetryProviderConfig()) + if not isinstance(provider_config, TelemetryProviderConfig): + provider_config = TelemetryProviderConfig.model_validate(provider_config) + if config is not None: + provider_config.config = dict(config) + self.providers["native"] = provider_config return self - def disable(self) -> Self: - """Disable telemetry.""" + def remove_provider(self, provider: Literal["relay", "native"]) -> Self: + """Remove a configured telemetry provider.""" - self.enabled = False + self.providers.pop(provider, None) return self @@ -285,6 +406,7 @@ class FabricConfig(FabricBaseModel): mcp: McpConfig | None = None skills: SkillConfig | None = None telemetry: TelemetryConfig | None = None + relay: RelayConfig | dict[str, Any] | None = None profiles: ProfileRegistryConfig | dict[str, Any] | None = None tools: Any = None @@ -355,16 +477,30 @@ def enable_relay( *, project: str | None = None, output_dir: str | Path | None = None, - config: Mapping[str, Any] | None = None, + observability: RelayObservabilityConfig | Mapping[str, Any] | None = None, + components: Sequence[RelayComponentConfig | Mapping[str, Any]] | None = None, + policy: RelayConfigPolicy | Mapping[str, Any] | None = None, ) -> Self: """Enable NeMo Relay telemetry and return this config.""" if self.telemetry is None: self.telemetry = TelemetryConfig() - self.telemetry.enable_relay( + self.telemetry.enable_relay() + relay_observability = ( + observability + if observability is None or isinstance(observability, RelayObservabilityConfig) + else dict(observability) + ) + relay_components = [ + item if isinstance(item, RelayComponentConfig) else dict(item) for item in (components or []) + ] + relay_policy = policy if policy is None or isinstance(policy, RelayConfigPolicy) else dict(policy) + self.relay = RelayConfig( project=project, output_dir=output_dir, - config=config, + observability=relay_observability, + components=relay_components, + policy=relay_policy, ) return self @@ -382,6 +518,7 @@ class FabricProfileConfig(FabricBaseModel): mcp: McpConfig | dict[str, Any] | None = None skills: SkillConfig | dict[str, Any] | None = None telemetry: TelemetryConfig | dict[str, Any] | None = None + relay: RelayConfig | dict[str, Any] | None = None tools: Any = None diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index 393fad5dd..f75018d61 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -6,11 +6,14 @@ from __future__ import annotations import math -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Iterator +from collections.abc import Mapping +from collections.abc import Sequence from copy import deepcopy from pathlib import Path from types import MappingProxyType -from typing import Any, TypeVar +from typing import Any +from typing import TypeVar from pydantic import BaseModel @@ -101,12 +104,9 @@ def __init__( extras = _mapping({} if extra_fields is None else extra_fields, "extra_fields") overlap = self._fields.intersection(extras) if overlap: - raise FabricConfigError( - f"extra_fields duplicates known fields: {', '.join(sorted(overlap))}" - ) + raise FabricConfigError(f"extra_fields duplicates known fields: {', '.join(sorted(overlap))}") stored = { - key: deepcopy(item) if isinstance(item, _ConfigMapping) else _plain(item) - for key, item in values.items() + key: deepcopy(item) if isinstance(item, _ConfigMapping) else _plain(item) for key, item in values.items() } super().__init__({**stored, **extras}) @@ -130,11 +130,7 @@ def __setattr__(self, name: str, value: Any) -> None: def extra_fields(self) -> dict[str, Any]: """Return preserved schema-extension fields as a deep copy.""" - return { - key: _plain(value) - for key, value in self.items() - if key not in self._fields - } + return {key: _plain(value) for key, value in self.items() if key not in self._fields} def to_mapping(self) -> dict[str, Any]: """Return a detached, JSON-compatible mapping for serialization.""" @@ -265,11 +261,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> "_RuntimeConfig": input_schema=data.get("input_schema"), output_schema=data.get("output_schema"), artifacts=data.get("artifacts"), - extra_fields={ - key: item - for key, item in data.items() - if key not in cls._fields - }, + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, ) @@ -285,9 +277,7 @@ class _EnvironmentConfig(_ConfigMapping): extra_fields: Preserved extension fields not recognized by this SDK. """ - _fields = frozenset( - {"provider", "workspace", "artifacts", "settings", "metadata"} - ) + _fields = frozenset({"provider", "workspace", "artifacts", "settings", "metadata"}) _omit_if_empty = frozenset({"settings", "metadata"}) def __init__( @@ -348,9 +338,7 @@ def __init__( paths: Sequence[str | Path] | None = None, extra_fields: Mapping[str, Any] | None = None, ) -> None: - values: dict[str, Any] = { - "paths": [str(path) for path in ([] if paths is None else paths)] - } + values: dict[str, Any] = {"paths": [str(path) for path in ([] if paths is None else paths)]} super().__init__(values, extra_fields=extra_fields) @classmethod @@ -394,9 +382,7 @@ def __init__( servers: Mapping[str, Any] | None = None, extra_fields: Mapping[str, Any] | None = None, ) -> None: - values: dict[str, Any] = { - "servers": _mapping({} if servers is None else servers, "mcp servers") - } + values: dict[str, Any] = {"servers": _mapping({} if servers is None else servers, "mcp servers")} super().__init__(values, extra_fields=extra_fields) @classmethod @@ -451,28 +437,16 @@ def remove_server(self, name: str) -> "_McpConfig": class _TelemetryConfig(_ConfigMapping): """Telemetry configuration with authoring helpers.""" - _fields = frozenset({"enabled", "provider", "project", "output_dir", "config"}) + _fields = frozenset({"providers"}) _PROVIDERS = frozenset({"relay", "native"}) def __init__( self, *, - enabled: bool = False, - provider: str | None = None, - project: str | None = None, - output_dir: str | Path | None = None, - config: Mapping[str, Any] | None = None, + providers: Mapping[str, Any] | None = None, extra_fields: Mapping[str, Any] | None = None, ) -> None: - values: dict[str, Any] = {"enabled": _boolean(enabled, "telemetry enabled")} - if provider is not None: - values["provider"] = self._provider(provider) - if project is not None: - values["project"] = project - if output_dir is not None: - values["output_dir"] = output_dir - if config is not None: - values["config"] = config + values: dict[str, Any] = {"providers": self._providers(providers or {})} super().__init__(values, extra_fields=extra_fields) @classmethod @@ -481,54 +455,58 @@ def from_mapping(cls, value: Mapping[str, Any]) -> "_TelemetryConfig": data = _mapping(value, "telemetry") return cls( - enabled=data.get("enabled", False), - provider=data.get("provider"), - project=data.get("project"), - output_dir=data.get("output_dir"), - config=data.get("config"), + providers=data.get("providers", {}), extra_fields={key: item for key, item in data.items() if key not in cls._fields}, ) @classmethod - def _provider(cls, provider: str) -> str: - value = _required_text(provider, "telemetry provider") - if value not in cls._PROVIDERS: - allowed = ", ".join(sorted(cls._PROVIDERS)) - raise FabricConfigError(f"telemetry provider must be one of: {allowed}") - return value + def _providers(cls, providers: Mapping[str, Any]) -> dict[str, Any]: + values: dict[str, Any] = {} + for key, value in _mapping(providers, "telemetry providers").items(): + provider = _required_text(key, "telemetry provider") + if provider not in cls._PROVIDERS: + allowed = ", ".join(sorted(cls._PROVIDERS)) + raise FabricConfigError(f"telemetry provider must be one of: {allowed}") + values[provider] = _mapping(value, f"{provider} telemetry provider") + return values def enable_relay( self, - *, - project: str | None = None, - output_dir: str | Path | None = None, - config: Mapping[str, Any] | None = None, ) -> "_TelemetryConfig": """Enable NeMo Relay telemetry for subsequently started runtimes.""" - self["enabled"] = True - self["provider"] = "relay" - if project is not None: - self["project"] = project - if output_dir is not None: - self["output_dir"] = str(output_dir) - if config is not None: - self["config"] = _mapping(config, "telemetry config") + providers = dict(self.get("providers", {})) + providers["relay"] = {} + self["providers"] = providers return self - def enable_native(self) -> "_TelemetryConfig": + def enable_native(self, *, config: Mapping[str, Any] | None = None) -> "_TelemetryConfig": """Let the selected harness adapter handle telemetry natively.""" - self["enabled"] = True - self["provider"] = "native" + providers = dict(self.get("providers", {})) + provider_config: dict[str, Any] = dict(providers.get("native", {})) + if config is not None: + provider_config["config"] = _mapping(config, "native telemetry config") + providers["native"] = provider_config + self["providers"] = providers return self - def disable(self) -> "_TelemetryConfig": - """Disable telemetry for subsequently started runtimes.""" + def remove_provider(self, provider: str) -> "_TelemetryConfig": + """Remove a configured telemetry provider.""" - self["enabled"] = False + providers = dict(self.get("providers", {})) + providers.pop(self._provider(provider), None) + self["providers"] = providers return self + @classmethod + def _provider(cls, provider: str) -> str: + value = _required_text(provider, "telemetry provider") + if value not in cls._PROVIDERS: + allowed = ", ".join(sorted(cls._PROVIDERS)) + raise FabricConfigError(f"telemetry provider must be one of: {allowed}") + return value + class _ResolvedFabricConfig(_ConfigMapping): """Mutable typed representation of a Fabric agent configuration. @@ -547,6 +525,7 @@ class _ResolvedFabricConfig(_ConfigMapping): mcp: Optional MCP configuration. skills: Optional skill configuration. telemetry: Optional telemetry configuration. + relay: Optional Relay integration configuration. profiles: Optional profile-discovery configuration. tools: Optional harness-neutral tool configuration. extra_fields: Preserved extension fields not recognized by this SDK. @@ -563,6 +542,7 @@ class _ResolvedFabricConfig(_ConfigMapping): "mcp", "skills", "telemetry", + "relay", "profiles", "tools", } @@ -581,6 +561,7 @@ def __init__( mcp: Mapping[str, Any] | None = None, skills: Mapping[str, Any] | None = None, telemetry: Mapping[str, Any] | None = None, + relay: Mapping[str, Any] | None = None, profiles: Mapping[str, Any] | None = None, tools: Any = None, extra_fields: Mapping[str, Any] | None = None, @@ -592,18 +573,11 @@ def __init__( _RuntimeConfig() if runtime is None else runtime, "runtime", ) - environment_value = ( - None - if environment is None - else _coerce(_EnvironmentConfig, environment, "environment") - ) + environment_value = None if environment is None else _coerce(_EnvironmentConfig, environment, "environment") mcp_value = None if mcp is None else _coerce(_McpConfig, mcp, "mcp") skills_value = None if skills is None else _coerce(_SkillConfig, skills, "skills") - telemetry_value = ( - None - if telemetry is None - else _coerce(_TelemetryConfig, telemetry, "telemetry") - ) + telemetry_value = None if telemetry is None else _coerce(_TelemetryConfig, telemetry, "telemetry") + relay_value = None if relay is None else _mapping(relay, "relay") values: dict[str, Any] = { "schema_version": _required_text(schema_version, "schema_version"), "metadata": metadata_value, @@ -616,6 +590,7 @@ def __init__( ("mcp", mcp_value), ("skills", skills_value), ("telemetry", telemetry_value), + ("relay", relay_value), ("profiles", profiles), ("tools", tools), ): @@ -642,6 +617,7 @@ def from_mapping(cls, value: Mapping[str, Any]) -> "_ResolvedFabricConfig": mcp=data.get("mcp"), skills=data.get("skills"), telemetry=data.get("telemetry"), + relay=data.get("relay"), profiles=data.get("profiles"), tools=data.get("tools"), extra_fields={key: item for key, item in data.items() if key not in cls._fields}, @@ -718,15 +694,25 @@ def enable_relay( *, project: str | None = None, output_dir: str | Path | None = None, - config: Mapping[str, Any] | None = None, + observability: Mapping[str, Any] | None = None, + components: Sequence[Mapping[str, Any]] | None = None, + policy: Mapping[str, Any] | None = None, ) -> "_ResolvedFabricConfig": """Enable NeMo Relay telemetry and return this config.""" - self.telemetry.enable_relay( - project=project, - output_dir=output_dir, - config=config, - ) + self.telemetry.enable_relay() + relay = dict(self.get("relay") or {}) + if project is not None: + relay["project"] = project + if output_dir is not None: + relay["output_dir"] = str(output_dir) + if observability is not None: + relay["observability"] = _mapping(observability, "relay observability") + if components is not None: + relay["components"] = [_mapping(component, "relay component") for component in components] + if policy is not None: + relay["policy"] = _mapping(policy, "relay policy") + self["relay"] = relay return self @@ -815,13 +801,7 @@ def __getattr__(self, name: str) -> Any: def extra_fields(self) -> Mapping[str, Any]: """Return an immutable view of preserved extension fields.""" - return MappingProxyType( - { - key: _thaw(value) - for key, value in self._data.items() - if key not in self._fields - } - ) + return MappingProxyType({key: _thaw(value) for key, value in self._data.items() if key not in self._fields}) def to_mapping(self) -> dict[str, Any]: """Return a detached, JSON-compatible mapping for serialization.""" @@ -922,18 +902,14 @@ class EffectiveConfig(FabricMapping): config_path: Path | None config_root: Path config: _ResolvedFabricConfig - _fields = frozenset( - {"agent_name", "profiles", "agent_root", "config_path", "config_root", "config"} - ) + _fields = frozenset({"agent_name", "profiles", "agent_root", "config_path", "config_root", "config"}) @classmethod def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: data["profiles"] = _required_profiles(data, "EffectiveConfig") data["agent_root"] = Path(data.get("agent_root", ".")) data["config_root"] = Path(data.get("config_root", ".")) - data["config_path"] = ( - None if data.get("config_path") is None else Path(data["config_path"]) - ) + data["config_path"] = None if data.get("config_path") is None else Path(data["config_path"]) data["config"] = _ResolvedFabricConfig.from_mapping(data.get("config", {})) return data @@ -954,9 +930,7 @@ class RunPlan(FabricMapping): profiles: Sequence[str] adapter: AdapterInfo capabilities: RuntimeCapabilities - _fields = frozenset( - {"effective_config", "agent_name", "profiles", "adapter", "capabilities"} - ) + _fields = frozenset({"effective_config", "agent_name", "profiles", "adapter", "capabilities"}) @classmethod def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: @@ -1012,9 +986,7 @@ class DoctorReport(FabricMapping): @classmethod def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: data["profiles"] = _required_profiles(data, "DoctorReport") - data["checks"] = tuple( - DoctorCheck.from_mapping(check) for check in data.get("checks", []) - ) + data["checks"] = tuple(DoctorCheck.from_mapping(check) for check in data.get("checks", [])) return data @@ -1084,9 +1056,7 @@ class ArtifactManifest(FabricMapping): @classmethod def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: data["root"] = None if data.get("root") is None else Path(data["root"]) - data["artifacts"] = tuple( - ArtifactRef.from_mapping(artifact) for artifact in data.get("artifacts", []) - ) + data["artifacts"] = tuple(ArtifactRef.from_mapping(artifact) for artifact in data.get("artifacts", [])) return data @@ -1113,9 +1083,13 @@ class TelemetryRef(FabricMapping): def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: metadata = _mapping(data.get("metadata", {}), "telemetry metadata") if "relay_enabled" in data: + providers = metadata.get("telemetry_providers") + provider = ( + providers[0] if isinstance(providers, list) and providers and isinstance(providers[0], str) else "relay" + ) metadata.setdefault("relay_enabled", data["relay_enabled"]) data = { - "provider": metadata.get("telemetry_provider", "relay"), + "provider": provider, "kind": "trace", "uri": metadata.get("relay_output_dir"), "trace_id": metadata.get("trace_id"), @@ -1298,24 +1272,16 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: "status", ): data[field] = _required_text(data.get(field), field.replace("_", " ")) - data["error"] = ( - None if data.get("error") is None else ErrorInfo.from_mapping(data["error"]) - ) - data["artifacts"] = ArtifactManifest.from_mapping( - data.get("artifacts", {"artifacts": []}) - ) + data["error"] = None if data.get("error") is None else ErrorInfo.from_mapping(data["error"]) + data["artifacts"] = ArtifactManifest.from_mapping(data.get("artifacts", {"artifacts": []})) telemetry = data.get("telemetry") if telemetry is None: data["telemetry"] = () elif isinstance(telemetry, Mapping): data["telemetry"] = (TelemetryRef.from_mapping(telemetry),) else: - data["telemetry"] = tuple( - TelemetryRef.from_mapping(item) for item in telemetry - ) - data["events"] = tuple( - FabricEvent.from_mapping(event) for event in data.get("events", []) - ) + data["telemetry"] = tuple(TelemetryRef.from_mapping(item) for item in telemetry) + data["events"] = tuple(FabricEvent.from_mapping(event) for event in data.get("events", [])) data["metadata"] = _mapping(data.get("metadata", {}), "result metadata") raw_output = data.get("output") if isinstance(raw_output, RunOutput): diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index eea68e66e..488e511f5 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -417,6 +417,17 @@ "$ref": "#/$defs/ProfileRegistryConfig", "description": "Optional profile discovery config." }, + "relay": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfig" + }, + { + "type": "null" + } + ], + "description": "First-class NeMo Relay integration configuration." + }, "runtime": { "$ref": "#/$defs/RuntimeConfig", "description": "Runtime input/output contract." @@ -639,6 +650,577 @@ }, "type": "object" }, + "RelayAtifConfig": { + "additionalProperties": true, + "description": "Relay ATIF export configuration.", + "properties": { + "agent_name": { + "default": "NeMo Relay", + "description": "Agent name written into ATIF.", + "type": "string" + }, + "agent_version": { + "description": "Agent version written into ATIF.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": false, + "description": "Whether ATIF export is enabled.", + "type": "boolean" + }, + "extra": { + "description": "Extra ATIF metadata." + }, + "filename_template": { + "default": "nemo-relay-atif-{session_id}.json", + "description": "ATIF file name template.", + "type": "string" + }, + "model_name": { + "default": "unknown", + "description": "Model name written into ATIF.", + "type": "string" + }, + "output_directory": { + "description": "Directory used for ATIF files.", + "type": [ + "string", + "null" + ] + }, + "storage": { + "description": "Optional ATIF remote storage.", + "items": { + "$ref": "#/$defs/RelayAtifStorageConfig" + }, + "type": [ + "array", + "null" + ] + }, + "tool_definitions": { + "description": "Tool definitions written into ATIF.", + "items": true, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtifStorageConfig": { + "description": "Relay ATIF remote storage configuration.", + "oneOf": [ + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to HTTP storage.", + "properties": { + "endpoint": { + "default": "", + "description": "HTTP storage endpoint.", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed HTTP headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static HTTP headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "http", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to S3-compatible storage.", + "properties": { + "access_key_id": { + "description": "AWS access key id.", + "type": [ + "string", + "null" + ] + }, + "allow_http": { + "description": "Allow HTTP endpoints for S3-compatible storage.", + "type": [ + "boolean", + "null" + ] + }, + "bucket": { + "default": "", + "description": "S3 bucket name.", + "type": "string" + }, + "endpoint_url": { + "description": "S3-compatible endpoint URL.", + "type": [ + "string", + "null" + ] + }, + "key_prefix": { + "description": "Optional S3 object key prefix.", + "type": [ + "string", + "null" + ] + }, + "region": { + "description": "AWS region.", + "type": [ + "string", + "null" + ] + }, + "secret_access_key_var": { + "description": "Environment variable containing the AWS secret access key.", + "type": [ + "string", + "null" + ] + }, + "session_token_var": { + "description": "Environment variable containing the AWS session token.", + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "s3", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RelayAtofConfig": { + "additionalProperties": true, + "description": "Relay ATOF export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether ATOF export is enabled.", + "type": "boolean" + }, + "endpoints": { + "description": "Optional remote ATOF endpoints.", + "items": { + "$ref": "#/$defs/RelayAtofEndpointConfig" + }, + "type": "array" + }, + "filename": { + "description": "ATOF file name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/RelayAtofMode", + "default": "append", + "description": "File write mode." + }, + "output_directory": { + "description": "Directory used for ATOF files.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtofEndpointConfig": { + "additionalProperties": true, + "description": "Relay ATOF endpoint configuration.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Endpoint headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayAtofEndpointTransport", + "default": "http_post", + "description": "Endpoint transport." + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RelayAtofEndpointFieldNamePolicy": { + "description": "Relay ATOF endpoint field-name policy.", + "oneOf": [ + { + "const": "preserve", + "description": "Preserve field names.", + "type": "string" + }, + { + "const": "replace_dots", + "description": "Replace dots in field names.", + "type": "string" + } + ] + }, + "RelayAtofEndpointTransport": { + "description": "Relay ATOF endpoint transport.", + "oneOf": [ + { + "const": "http_post", + "description": "HTTP POST transport.", + "type": "string" + }, + { + "const": "websocket", + "description": "WebSocket transport.", + "type": "string" + }, + { + "const": "ndjson", + "description": "NDJSON transport.", + "type": "string" + } + ] + }, + "RelayAtofMode": { + "description": "Relay ATOF file mode.", + "oneOf": [ + { + "const": "append", + "description": "Append to an existing ATOF file.", + "type": "string" + }, + { + "const": "overwrite", + "description": "Overwrite an existing ATOF file.", + "type": "string" + } + ] + }, + "RelayComponentConfig": { + "additionalProperties": true, + "description": "Generic NeMo Relay plugin component configuration.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local Relay plugin config.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether this Relay component should be activated.", + "type": "boolean" + }, + "kind": { + "description": "Registered Relay plugin kind.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "RelayConfig": { + "additionalProperties": true, + "description": "NeMo Relay integration configuration.", + "properties": { + "components": { + "description": "Additional Relay plugin components.", + "items": { + "$ref": "#/$defs/RelayComponentConfig" + }, + "type": "array" + }, + "observability": { + "anyOf": [ + { + "$ref": "#/$defs/RelayObservabilityConfig" + }, + { + "type": "null" + } + ], + "description": "Relay observability component configuration." + }, + "output_dir": { + "description": "Optional Relay output directory.", + "type": [ + "string", + "null" + ] + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay plugin validation policy." + }, + "project": { + "description": "Optional project name for Relay backends.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayConfigPolicy": { + "description": "Relay validation policy.", + "properties": { + "unknown_component": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown components." + }, + "unknown_field": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown fields." + }, + "unsupported_value": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "error", + "description": "Policy for unsupported values." + } + }, + "type": "object" + }, + "RelayObservabilityConfig": { + "additionalProperties": true, + "description": "NeMo Relay observability component configuration.", + "properties": { + "atif": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtifConfig" + }, + { + "type": "null" + } + ], + "description": "ATIF export configuration." + }, + "atof": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtofConfig" + }, + { + "type": "null" + } + ], + "description": "ATOF export configuration." + }, + "openinference": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenInference export configuration." + }, + "opentelemetry": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenTelemetry export configuration." + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay config validation policy." + }, + "version": { + "default": 1, + "description": "Relay observability config version.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RelayOtlpConfig": { + "additionalProperties": true, + "description": "Relay OpenTelemetry/OpenInference export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether OTLP export is enabled.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP endpoint.", + "type": [ + "string", + "null" + ] + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP headers.", + "type": "object" + }, + "instrumentation_scope": { + "description": "OTLP instrumentation scope.", + "type": [ + "string", + "null" + ] + }, + "resource_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP resource attributes.", + "type": "object" + }, + "service_name": { + "default": "nemo-relay", + "description": "OTLP service name.", + "type": "string" + }, + "service_namespace": { + "description": "OTLP service namespace.", + "type": [ + "string", + "null" + ] + }, + "service_version": { + "description": "OTLP service version.", + "type": [ + "string", + "null" + ] + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayOtlpTransport", + "default": "http_binary", + "description": "OTLP transport." + } + }, + "type": "object" + }, + "RelayOtlpTransport": { + "description": "Relay OTLP transport.", + "oneOf": [ + { + "const": "http_binary", + "description": "OTLP HTTP binary transport.", + "type": "string" + }, + { + "const": "grpc", + "description": "OTLP gRPC transport.", + "type": "string" + } + ] + }, + "RelayUnsupportedBehavior": { + "description": "Relay unsupported/unknown config handling.", + "oneOf": [ + { + "const": "ignore", + "description": "Ignore the unsupported or unknown value.", + "type": "string" + }, + { + "const": "warn", + "description": "Warn on the unsupported or unknown value.", + "type": "string" + }, + { + "const": "error", + "description": "Error on the unsupported or unknown value.", + "type": "string" + } + ] + }, "ResolutionStrategy": { "description": "Adapter install or availability strategy.", "oneOf": [ @@ -822,32 +1404,12 @@ "additionalProperties": true, "description": "Telemetry configuration.", "properties": { - "config": { - "description": "Pass-through telemetry backend config." - }, - "enabled": { - "default": false, - "description": "Whether telemetry is enabled for this run.", - "type": "boolean" - }, - "output_dir": { - "description": "Optional telemetry output directory.", - "type": [ - "string", - "null" - ] - }, - "project": { - "description": "Optional project name for telemetry backends.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "default": "relay", - "description": "Telemetry provider responsible for runtime integration." + "providers": { + "additionalProperties": { + "$ref": "#/$defs/TelemetryProviderConfig" + }, + "description": "Telemetry providers enabled for this run.", + "type": "object" } }, "type": "object" @@ -862,9 +1424,15 @@ }, "type": "array" }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "description": "Telemetry provider selected for this run." + "native_config": { + "description": "Native telemetry pass-through config." + }, + "providers": { + "description": "Telemetry providers selected for this run.", + "items": { + "$ref": "#/$defs/TelemetryProvider" + }, + "type": "array" }, "relay_config": { "description": "Relay pass-through config." @@ -889,7 +1457,7 @@ } }, "required": [ - "provider", + "providers", "relay_enabled" ], "type": "object" @@ -908,6 +1476,16 @@ "type": "string" } ] + }, + "TelemetryProviderConfig": { + "additionalProperties": true, + "description": "Provider-specific telemetry configuration.", + "properties": { + "config": { + "description": "Provider-specific pass-through config." + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index bb2ddc5b5..a15c83d31 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -240,6 +240,577 @@ }, "type": "object" }, + "RelayAtifConfig": { + "additionalProperties": true, + "description": "Relay ATIF export configuration.", + "properties": { + "agent_name": { + "default": "NeMo Relay", + "description": "Agent name written into ATIF.", + "type": "string" + }, + "agent_version": { + "description": "Agent version written into ATIF.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": false, + "description": "Whether ATIF export is enabled.", + "type": "boolean" + }, + "extra": { + "description": "Extra ATIF metadata." + }, + "filename_template": { + "default": "nemo-relay-atif-{session_id}.json", + "description": "ATIF file name template.", + "type": "string" + }, + "model_name": { + "default": "unknown", + "description": "Model name written into ATIF.", + "type": "string" + }, + "output_directory": { + "description": "Directory used for ATIF files.", + "type": [ + "string", + "null" + ] + }, + "storage": { + "description": "Optional ATIF remote storage.", + "items": { + "$ref": "#/$defs/RelayAtifStorageConfig" + }, + "type": [ + "array", + "null" + ] + }, + "tool_definitions": { + "description": "Tool definitions written into ATIF.", + "items": true, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtifStorageConfig": { + "description": "Relay ATIF remote storage configuration.", + "oneOf": [ + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to HTTP storage.", + "properties": { + "endpoint": { + "default": "", + "description": "HTTP storage endpoint.", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed HTTP headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static HTTP headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "http", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to S3-compatible storage.", + "properties": { + "access_key_id": { + "description": "AWS access key id.", + "type": [ + "string", + "null" + ] + }, + "allow_http": { + "description": "Allow HTTP endpoints for S3-compatible storage.", + "type": [ + "boolean", + "null" + ] + }, + "bucket": { + "default": "", + "description": "S3 bucket name.", + "type": "string" + }, + "endpoint_url": { + "description": "S3-compatible endpoint URL.", + "type": [ + "string", + "null" + ] + }, + "key_prefix": { + "description": "Optional S3 object key prefix.", + "type": [ + "string", + "null" + ] + }, + "region": { + "description": "AWS region.", + "type": [ + "string", + "null" + ] + }, + "secret_access_key_var": { + "description": "Environment variable containing the AWS secret access key.", + "type": [ + "string", + "null" + ] + }, + "session_token_var": { + "description": "Environment variable containing the AWS session token.", + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "s3", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RelayAtofConfig": { + "additionalProperties": true, + "description": "Relay ATOF export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether ATOF export is enabled.", + "type": "boolean" + }, + "endpoints": { + "description": "Optional remote ATOF endpoints.", + "items": { + "$ref": "#/$defs/RelayAtofEndpointConfig" + }, + "type": "array" + }, + "filename": { + "description": "ATOF file name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/RelayAtofMode", + "default": "append", + "description": "File write mode." + }, + "output_directory": { + "description": "Directory used for ATOF files.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtofEndpointConfig": { + "additionalProperties": true, + "description": "Relay ATOF endpoint configuration.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Endpoint headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayAtofEndpointTransport", + "default": "http_post", + "description": "Endpoint transport." + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RelayAtofEndpointFieldNamePolicy": { + "description": "Relay ATOF endpoint field-name policy.", + "oneOf": [ + { + "const": "preserve", + "description": "Preserve field names.", + "type": "string" + }, + { + "const": "replace_dots", + "description": "Replace dots in field names.", + "type": "string" + } + ] + }, + "RelayAtofEndpointTransport": { + "description": "Relay ATOF endpoint transport.", + "oneOf": [ + { + "const": "http_post", + "description": "HTTP POST transport.", + "type": "string" + }, + { + "const": "websocket", + "description": "WebSocket transport.", + "type": "string" + }, + { + "const": "ndjson", + "description": "NDJSON transport.", + "type": "string" + } + ] + }, + "RelayAtofMode": { + "description": "Relay ATOF file mode.", + "oneOf": [ + { + "const": "append", + "description": "Append to an existing ATOF file.", + "type": "string" + }, + { + "const": "overwrite", + "description": "Overwrite an existing ATOF file.", + "type": "string" + } + ] + }, + "RelayComponentConfig": { + "additionalProperties": true, + "description": "Generic NeMo Relay plugin component configuration.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local Relay plugin config.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether this Relay component should be activated.", + "type": "boolean" + }, + "kind": { + "description": "Registered Relay plugin kind.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "RelayConfig": { + "additionalProperties": true, + "description": "NeMo Relay integration configuration.", + "properties": { + "components": { + "description": "Additional Relay plugin components.", + "items": { + "$ref": "#/$defs/RelayComponentConfig" + }, + "type": "array" + }, + "observability": { + "anyOf": [ + { + "$ref": "#/$defs/RelayObservabilityConfig" + }, + { + "type": "null" + } + ], + "description": "Relay observability component configuration." + }, + "output_dir": { + "description": "Optional Relay output directory.", + "type": [ + "string", + "null" + ] + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay plugin validation policy." + }, + "project": { + "description": "Optional project name for Relay backends.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayConfigPolicy": { + "description": "Relay validation policy.", + "properties": { + "unknown_component": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown components." + }, + "unknown_field": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown fields." + }, + "unsupported_value": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "error", + "description": "Policy for unsupported values." + } + }, + "type": "object" + }, + "RelayObservabilityConfig": { + "additionalProperties": true, + "description": "NeMo Relay observability component configuration.", + "properties": { + "atif": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtifConfig" + }, + { + "type": "null" + } + ], + "description": "ATIF export configuration." + }, + "atof": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtofConfig" + }, + { + "type": "null" + } + ], + "description": "ATOF export configuration." + }, + "openinference": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenInference export configuration." + }, + "opentelemetry": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenTelemetry export configuration." + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay config validation policy." + }, + "version": { + "default": 1, + "description": "Relay observability config version.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RelayOtlpConfig": { + "additionalProperties": true, + "description": "Relay OpenTelemetry/OpenInference export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether OTLP export is enabled.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP endpoint.", + "type": [ + "string", + "null" + ] + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP headers.", + "type": "object" + }, + "instrumentation_scope": { + "description": "OTLP instrumentation scope.", + "type": [ + "string", + "null" + ] + }, + "resource_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP resource attributes.", + "type": "object" + }, + "service_name": { + "default": "nemo-relay", + "description": "OTLP service name.", + "type": "string" + }, + "service_namespace": { + "description": "OTLP service namespace.", + "type": [ + "string", + "null" + ] + }, + "service_version": { + "description": "OTLP service version.", + "type": [ + "string", + "null" + ] + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayOtlpTransport", + "default": "http_binary", + "description": "OTLP transport." + } + }, + "type": "object" + }, + "RelayOtlpTransport": { + "description": "Relay OTLP transport.", + "oneOf": [ + { + "const": "http_binary", + "description": "OTLP HTTP binary transport.", + "type": "string" + }, + { + "const": "grpc", + "description": "OTLP gRPC transport.", + "type": "string" + } + ] + }, + "RelayUnsupportedBehavior": { + "description": "Relay unsupported/unknown config handling.", + "oneOf": [ + { + "const": "ignore", + "description": "Ignore the unsupported or unknown value.", + "type": "string" + }, + { + "const": "warn", + "description": "Warn on the unsupported or unknown value.", + "type": "string" + }, + { + "const": "error", + "description": "Error on the unsupported or unknown value.", + "type": "string" + } + ] + }, "ResolutionStrategy": { "description": "Adapter install or availability strategy.", "oneOf": [ @@ -322,50 +893,25 @@ "additionalProperties": true, "description": "Telemetry configuration.", "properties": { - "config": { - "description": "Pass-through telemetry backend config." - }, - "enabled": { - "default": false, - "description": "Whether telemetry is enabled for this run.", - "type": "boolean" - }, - "output_dir": { - "description": "Optional telemetry output directory.", - "type": [ - "string", - "null" - ] - }, - "project": { - "description": "Optional project name for telemetry backends.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "default": "relay", - "description": "Telemetry provider responsible for runtime integration." + "providers": { + "additionalProperties": { + "$ref": "#/$defs/TelemetryProviderConfig" + }, + "description": "Telemetry providers enabled for this run.", + "type": "object" } }, "type": "object" }, - "TelemetryProvider": { - "description": "Telemetry runtime provider.", - "oneOf": [ - { - "const": "relay", - "description": "Use NeMo Relay for telemetry integration.", - "type": "string" - }, - { - "const": "native", - "description": "Let the selected adapter handle telemetry natively.", - "type": "string" + "TelemetryProviderConfig": { + "additionalProperties": true, + "description": "Provider-specific telemetry configuration.", + "properties": { + "config": { + "description": "Provider-specific pass-through config." } - ] + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -413,6 +959,17 @@ "$ref": "#/$defs/ProfileRegistryConfig", "description": "Optional profile discovery config." }, + "relay": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfig" + }, + { + "type": "null" + } + ], + "description": "First-class NeMo Relay integration configuration." + }, "runtime": { "$ref": "#/$defs/RuntimeConfig", "description": "Runtime input/output contract." diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index 78bdaba2d..60fb1062d 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -128,6 +128,17 @@ "$ref": "#/$defs/ProfileRegistryConfig", "description": "Optional profile discovery config." }, + "relay": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfig" + }, + { + "type": "null" + } + ], + "description": "First-class NeMo Relay integration configuration." + }, "runtime": { "$ref": "#/$defs/RuntimeConfig", "description": "Runtime input/output contract." @@ -327,6 +338,577 @@ }, "type": "object" }, + "RelayAtifConfig": { + "additionalProperties": true, + "description": "Relay ATIF export configuration.", + "properties": { + "agent_name": { + "default": "NeMo Relay", + "description": "Agent name written into ATIF.", + "type": "string" + }, + "agent_version": { + "description": "Agent version written into ATIF.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": false, + "description": "Whether ATIF export is enabled.", + "type": "boolean" + }, + "extra": { + "description": "Extra ATIF metadata." + }, + "filename_template": { + "default": "nemo-relay-atif-{session_id}.json", + "description": "ATIF file name template.", + "type": "string" + }, + "model_name": { + "default": "unknown", + "description": "Model name written into ATIF.", + "type": "string" + }, + "output_directory": { + "description": "Directory used for ATIF files.", + "type": [ + "string", + "null" + ] + }, + "storage": { + "description": "Optional ATIF remote storage.", + "items": { + "$ref": "#/$defs/RelayAtifStorageConfig" + }, + "type": [ + "array", + "null" + ] + }, + "tool_definitions": { + "description": "Tool definitions written into ATIF.", + "items": true, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtifStorageConfig": { + "description": "Relay ATIF remote storage configuration.", + "oneOf": [ + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to HTTP storage.", + "properties": { + "endpoint": { + "default": "", + "description": "HTTP storage endpoint.", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed HTTP headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static HTTP headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "http", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to S3-compatible storage.", + "properties": { + "access_key_id": { + "description": "AWS access key id.", + "type": [ + "string", + "null" + ] + }, + "allow_http": { + "description": "Allow HTTP endpoints for S3-compatible storage.", + "type": [ + "boolean", + "null" + ] + }, + "bucket": { + "default": "", + "description": "S3 bucket name.", + "type": "string" + }, + "endpoint_url": { + "description": "S3-compatible endpoint URL.", + "type": [ + "string", + "null" + ] + }, + "key_prefix": { + "description": "Optional S3 object key prefix.", + "type": [ + "string", + "null" + ] + }, + "region": { + "description": "AWS region.", + "type": [ + "string", + "null" + ] + }, + "secret_access_key_var": { + "description": "Environment variable containing the AWS secret access key.", + "type": [ + "string", + "null" + ] + }, + "session_token_var": { + "description": "Environment variable containing the AWS session token.", + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "s3", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RelayAtofConfig": { + "additionalProperties": true, + "description": "Relay ATOF export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether ATOF export is enabled.", + "type": "boolean" + }, + "endpoints": { + "description": "Optional remote ATOF endpoints.", + "items": { + "$ref": "#/$defs/RelayAtofEndpointConfig" + }, + "type": "array" + }, + "filename": { + "description": "ATOF file name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/RelayAtofMode", + "default": "append", + "description": "File write mode." + }, + "output_directory": { + "description": "Directory used for ATOF files.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtofEndpointConfig": { + "additionalProperties": true, + "description": "Relay ATOF endpoint configuration.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Endpoint headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayAtofEndpointTransport", + "default": "http_post", + "description": "Endpoint transport." + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RelayAtofEndpointFieldNamePolicy": { + "description": "Relay ATOF endpoint field-name policy.", + "oneOf": [ + { + "const": "preserve", + "description": "Preserve field names.", + "type": "string" + }, + { + "const": "replace_dots", + "description": "Replace dots in field names.", + "type": "string" + } + ] + }, + "RelayAtofEndpointTransport": { + "description": "Relay ATOF endpoint transport.", + "oneOf": [ + { + "const": "http_post", + "description": "HTTP POST transport.", + "type": "string" + }, + { + "const": "websocket", + "description": "WebSocket transport.", + "type": "string" + }, + { + "const": "ndjson", + "description": "NDJSON transport.", + "type": "string" + } + ] + }, + "RelayAtofMode": { + "description": "Relay ATOF file mode.", + "oneOf": [ + { + "const": "append", + "description": "Append to an existing ATOF file.", + "type": "string" + }, + { + "const": "overwrite", + "description": "Overwrite an existing ATOF file.", + "type": "string" + } + ] + }, + "RelayComponentConfig": { + "additionalProperties": true, + "description": "Generic NeMo Relay plugin component configuration.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local Relay plugin config.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether this Relay component should be activated.", + "type": "boolean" + }, + "kind": { + "description": "Registered Relay plugin kind.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "RelayConfig": { + "additionalProperties": true, + "description": "NeMo Relay integration configuration.", + "properties": { + "components": { + "description": "Additional Relay plugin components.", + "items": { + "$ref": "#/$defs/RelayComponentConfig" + }, + "type": "array" + }, + "observability": { + "anyOf": [ + { + "$ref": "#/$defs/RelayObservabilityConfig" + }, + { + "type": "null" + } + ], + "description": "Relay observability component configuration." + }, + "output_dir": { + "description": "Optional Relay output directory.", + "type": [ + "string", + "null" + ] + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay plugin validation policy." + }, + "project": { + "description": "Optional project name for Relay backends.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayConfigPolicy": { + "description": "Relay validation policy.", + "properties": { + "unknown_component": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown components." + }, + "unknown_field": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown fields." + }, + "unsupported_value": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "error", + "description": "Policy for unsupported values." + } + }, + "type": "object" + }, + "RelayObservabilityConfig": { + "additionalProperties": true, + "description": "NeMo Relay observability component configuration.", + "properties": { + "atif": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtifConfig" + }, + { + "type": "null" + } + ], + "description": "ATIF export configuration." + }, + "atof": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtofConfig" + }, + { + "type": "null" + } + ], + "description": "ATOF export configuration." + }, + "openinference": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenInference export configuration." + }, + "opentelemetry": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenTelemetry export configuration." + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay config validation policy." + }, + "version": { + "default": 1, + "description": "Relay observability config version.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RelayOtlpConfig": { + "additionalProperties": true, + "description": "Relay OpenTelemetry/OpenInference export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether OTLP export is enabled.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP endpoint.", + "type": [ + "string", + "null" + ] + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP headers.", + "type": "object" + }, + "instrumentation_scope": { + "description": "OTLP instrumentation scope.", + "type": [ + "string", + "null" + ] + }, + "resource_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP resource attributes.", + "type": "object" + }, + "service_name": { + "default": "nemo-relay", + "description": "OTLP service name.", + "type": "string" + }, + "service_namespace": { + "description": "OTLP service namespace.", + "type": [ + "string", + "null" + ] + }, + "service_version": { + "description": "OTLP service version.", + "type": [ + "string", + "null" + ] + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayOtlpTransport", + "default": "http_binary", + "description": "OTLP transport." + } + }, + "type": "object" + }, + "RelayOtlpTransport": { + "description": "Relay OTLP transport.", + "oneOf": [ + { + "const": "http_binary", + "description": "OTLP HTTP binary transport.", + "type": "string" + }, + { + "const": "grpc", + "description": "OTLP gRPC transport.", + "type": "string" + } + ] + }, + "RelayUnsupportedBehavior": { + "description": "Relay unsupported/unknown config handling.", + "oneOf": [ + { + "const": "ignore", + "description": "Ignore the unsupported or unknown value.", + "type": "string" + }, + { + "const": "warn", + "description": "Warn on the unsupported or unknown value.", + "type": "string" + }, + { + "const": "error", + "description": "Error on the unsupported or unknown value.", + "type": "string" + } + ] + }, "ResolutionStrategy": { "description": "Adapter install or availability strategy.", "oneOf": [ @@ -409,50 +991,25 @@ "additionalProperties": true, "description": "Telemetry configuration.", "properties": { - "config": { - "description": "Pass-through telemetry backend config." - }, - "enabled": { - "default": false, - "description": "Whether telemetry is enabled for this run.", - "type": "boolean" - }, - "output_dir": { - "description": "Optional telemetry output directory.", - "type": [ - "string", - "null" - ] - }, - "project": { - "description": "Optional project name for telemetry backends.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "default": "relay", - "description": "Telemetry provider responsible for runtime integration." + "providers": { + "additionalProperties": { + "$ref": "#/$defs/TelemetryProviderConfig" + }, + "description": "Telemetry providers enabled for this run.", + "type": "object" } }, "type": "object" }, - "TelemetryProvider": { - "description": "Telemetry runtime provider.", - "oneOf": [ - { - "const": "relay", - "description": "Use NeMo Relay for telemetry integration.", - "type": "string" - }, - { - "const": "native", - "description": "Let the selected adapter handle telemetry natively.", - "type": "string" + "TelemetryProviderConfig": { + "additionalProperties": true, + "description": "Provider-specific telemetry configuration.", + "properties": { + "config": { + "description": "Provider-specific pass-through config." } - ] + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/profile.schema.json b/schemas/profile.schema.json index 4e9629414..085fd50df 100644 --- a/schemas/profile.schema.json +++ b/schemas/profile.schema.json @@ -48,6 +48,14 @@ "null" ] }, + "relay": { + "additionalProperties": true, + "description": "Partial Relay integration overlay.", + "type": [ + "object", + "null" + ] + }, "runtime": { "additionalProperties": true, "description": "Partial runtime overlay.", diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 419962680..1f14a4793 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -546,6 +546,17 @@ "$ref": "#/$defs/ProfileRegistryConfig", "description": "Optional profile discovery config." }, + "relay": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfig" + }, + { + "type": "null" + } + ], + "description": "First-class NeMo Relay integration configuration." + }, "runtime": { "$ref": "#/$defs/RuntimeConfig", "description": "Runtime input/output contract." @@ -768,6 +779,577 @@ }, "type": "object" }, + "RelayAtifConfig": { + "additionalProperties": true, + "description": "Relay ATIF export configuration.", + "properties": { + "agent_name": { + "default": "NeMo Relay", + "description": "Agent name written into ATIF.", + "type": "string" + }, + "agent_version": { + "description": "Agent version written into ATIF.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "default": false, + "description": "Whether ATIF export is enabled.", + "type": "boolean" + }, + "extra": { + "description": "Extra ATIF metadata." + }, + "filename_template": { + "default": "nemo-relay-atif-{session_id}.json", + "description": "ATIF file name template.", + "type": "string" + }, + "model_name": { + "default": "unknown", + "description": "Model name written into ATIF.", + "type": "string" + }, + "output_directory": { + "description": "Directory used for ATIF files.", + "type": [ + "string", + "null" + ] + }, + "storage": { + "description": "Optional ATIF remote storage.", + "items": { + "$ref": "#/$defs/RelayAtifStorageConfig" + }, + "type": [ + "array", + "null" + ] + }, + "tool_definitions": { + "description": "Tool definitions written into ATIF.", + "items": true, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtifStorageConfig": { + "description": "Relay ATIF remote storage configuration.", + "oneOf": [ + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to HTTP storage.", + "properties": { + "endpoint": { + "default": "", + "description": "HTTP storage endpoint.", + "type": "string" + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed HTTP headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static HTTP headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "http", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": true, + "description": "Upload ATIF artifacts to S3-compatible storage.", + "properties": { + "access_key_id": { + "description": "AWS access key id.", + "type": [ + "string", + "null" + ] + }, + "allow_http": { + "description": "Allow HTTP endpoints for S3-compatible storage.", + "type": [ + "boolean", + "null" + ] + }, + "bucket": { + "default": "", + "description": "S3 bucket name.", + "type": "string" + }, + "endpoint_url": { + "description": "S3-compatible endpoint URL.", + "type": [ + "string", + "null" + ] + }, + "key_prefix": { + "description": "Optional S3 object key prefix.", + "type": [ + "string", + "null" + ] + }, + "region": { + "description": "AWS region.", + "type": [ + "string", + "null" + ] + }, + "secret_access_key_var": { + "description": "Environment variable containing the AWS secret access key.", + "type": [ + "string", + "null" + ] + }, + "session_token_var": { + "description": "Environment variable containing the AWS session token.", + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "s3", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ] + }, + "RelayAtofConfig": { + "additionalProperties": true, + "description": "Relay ATOF export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether ATOF export is enabled.", + "type": "boolean" + }, + "endpoints": { + "description": "Optional remote ATOF endpoints.", + "items": { + "$ref": "#/$defs/RelayAtofEndpointConfig" + }, + "type": "array" + }, + "filename": { + "description": "ATOF file name.", + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/$defs/RelayAtofMode", + "default": "append", + "description": "File write mode." + }, + "output_directory": { + "description": "Directory used for ATOF files.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayAtofEndpointConfig": { + "additionalProperties": true, + "description": "Relay ATOF endpoint configuration.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Endpoint headers.", + "type": "object" + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayAtofEndpointTransport", + "default": "http_post", + "description": "Endpoint transport." + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RelayAtofEndpointFieldNamePolicy": { + "description": "Relay ATOF endpoint field-name policy.", + "oneOf": [ + { + "const": "preserve", + "description": "Preserve field names.", + "type": "string" + }, + { + "const": "replace_dots", + "description": "Replace dots in field names.", + "type": "string" + } + ] + }, + "RelayAtofEndpointTransport": { + "description": "Relay ATOF endpoint transport.", + "oneOf": [ + { + "const": "http_post", + "description": "HTTP POST transport.", + "type": "string" + }, + { + "const": "websocket", + "description": "WebSocket transport.", + "type": "string" + }, + { + "const": "ndjson", + "description": "NDJSON transport.", + "type": "string" + } + ] + }, + "RelayAtofMode": { + "description": "Relay ATOF file mode.", + "oneOf": [ + { + "const": "append", + "description": "Append to an existing ATOF file.", + "type": "string" + }, + { + "const": "overwrite", + "description": "Overwrite an existing ATOF file.", + "type": "string" + } + ] + }, + "RelayComponentConfig": { + "additionalProperties": true, + "description": "Generic NeMo Relay plugin component configuration.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local Relay plugin config.", + "type": "object" + }, + "enabled": { + "default": true, + "description": "Whether this Relay component should be activated.", + "type": "boolean" + }, + "kind": { + "description": "Registered Relay plugin kind.", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "RelayConfig": { + "additionalProperties": true, + "description": "NeMo Relay integration configuration.", + "properties": { + "components": { + "description": "Additional Relay plugin components.", + "items": { + "$ref": "#/$defs/RelayComponentConfig" + }, + "type": "array" + }, + "observability": { + "anyOf": [ + { + "$ref": "#/$defs/RelayObservabilityConfig" + }, + { + "type": "null" + } + ], + "description": "Relay observability component configuration." + }, + "output_dir": { + "description": "Optional Relay output directory.", + "type": [ + "string", + "null" + ] + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay plugin validation policy." + }, + "project": { + "description": "Optional project name for Relay backends.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RelayConfigPolicy": { + "description": "Relay validation policy.", + "properties": { + "unknown_component": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown components." + }, + "unknown_field": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "warn", + "description": "Policy for unknown fields." + }, + "unsupported_value": { + "$ref": "#/$defs/RelayUnsupportedBehavior", + "default": "error", + "description": "Policy for unsupported values." + } + }, + "type": "object" + }, + "RelayObservabilityConfig": { + "additionalProperties": true, + "description": "NeMo Relay observability component configuration.", + "properties": { + "atif": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtifConfig" + }, + { + "type": "null" + } + ], + "description": "ATIF export configuration." + }, + "atof": { + "anyOf": [ + { + "$ref": "#/$defs/RelayAtofConfig" + }, + { + "type": "null" + } + ], + "description": "ATOF export configuration." + }, + "openinference": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenInference export configuration." + }, + "opentelemetry": { + "anyOf": [ + { + "$ref": "#/$defs/RelayOtlpConfig" + }, + { + "type": "null" + } + ], + "description": "OpenTelemetry export configuration." + }, + "policy": { + "anyOf": [ + { + "$ref": "#/$defs/RelayConfigPolicy" + }, + { + "type": "null" + } + ], + "description": "Relay config validation policy." + }, + "version": { + "default": 1, + "description": "Relay observability config version.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RelayOtlpConfig": { + "additionalProperties": true, + "description": "Relay OpenTelemetry/OpenInference export configuration.", + "properties": { + "enabled": { + "default": false, + "description": "Whether OTLP export is enabled.", + "type": "boolean" + }, + "endpoint": { + "description": "OTLP endpoint.", + "type": [ + "string", + "null" + ] + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP headers.", + "type": "object" + }, + "instrumentation_scope": { + "description": "OTLP instrumentation scope.", + "type": [ + "string", + "null" + ] + }, + "resource_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "OTLP resource attributes.", + "type": "object" + }, + "service_name": { + "default": "nemo-relay", + "description": "OTLP service name.", + "type": "string" + }, + "service_namespace": { + "description": "OTLP service namespace.", + "type": [ + "string", + "null" + ] + }, + "service_version": { + "description": "OTLP service version.", + "type": [ + "string", + "null" + ] + }, + "timeout_millis": { + "default": 3000, + "description": "Request timeout in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "transport": { + "$ref": "#/$defs/RelayOtlpTransport", + "default": "http_binary", + "description": "OTLP transport." + } + }, + "type": "object" + }, + "RelayOtlpTransport": { + "description": "Relay OTLP transport.", + "oneOf": [ + { + "const": "http_binary", + "description": "OTLP HTTP binary transport.", + "type": "string" + }, + { + "const": "grpc", + "description": "OTLP gRPC transport.", + "type": "string" + } + ] + }, + "RelayUnsupportedBehavior": { + "description": "Relay unsupported/unknown config handling.", + "oneOf": [ + { + "const": "ignore", + "description": "Ignore the unsupported or unknown value.", + "type": "string" + }, + { + "const": "warn", + "description": "Warn on the unsupported or unknown value.", + "type": "string" + }, + { + "const": "error", + "description": "Error on the unsupported or unknown value.", + "type": "string" + } + ] + }, "ResolutionStrategy": { "description": "Adapter install or availability strategy.", "oneOf": [ @@ -909,32 +1491,12 @@ "additionalProperties": true, "description": "Telemetry configuration.", "properties": { - "config": { - "description": "Pass-through telemetry backend config." - }, - "enabled": { - "default": false, - "description": "Whether telemetry is enabled for this run.", - "type": "boolean" - }, - "output_dir": { - "description": "Optional telemetry output directory.", - "type": [ - "string", - "null" - ] - }, - "project": { - "description": "Optional project name for telemetry backends.", - "type": [ - "string", - "null" - ] - }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "default": "relay", - "description": "Telemetry provider responsible for runtime integration." + "providers": { + "additionalProperties": { + "$ref": "#/$defs/TelemetryProviderConfig" + }, + "description": "Telemetry providers enabled for this run.", + "type": "object" } }, "type": "object" @@ -949,9 +1511,15 @@ }, "type": "array" }, - "provider": { - "$ref": "#/$defs/TelemetryProvider", - "description": "Telemetry provider selected for this run." + "native_config": { + "description": "Native telemetry pass-through config." + }, + "providers": { + "description": "Telemetry providers selected for this run.", + "items": { + "$ref": "#/$defs/TelemetryProvider" + }, + "type": "array" }, "relay_config": { "description": "Relay pass-through config." @@ -976,7 +1544,7 @@ } }, "required": [ - "provider", + "providers", "relay_enabled" ], "type": "object" @@ -995,6 +1563,16 @@ "type": "string" } ] + }, + "TelemetryProviderConfig": { + "additionalProperties": true, + "description": "Provider-specific telemetry configuration.", + "properties": { + "config": { + "description": "Provider-specific pass-through config." + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/tests/_utils/utils.py b/tests/_utils/utils.py index 9be1dfd99..b5570b937 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -44,9 +44,9 @@ def assert_relay_disabled_native_observability(result: dict): event_kinds = {event["kind"] for event in result["events"]} assert {"runtime_start", "invocation_start", "invocation_end"} <= event_kinds - telemetry = result["telemetry"] - assert telemetry is not None - assert telemetry["relay_enabled"] is False + telemetry = result.get("telemetry") + if telemetry is not None: + assert telemetry["relay_enabled"] is False def assert_process_adapter_native_observability(result: dict): diff --git a/tests/adapters/test_adapaters_common_hermes.py b/tests/adapters/test_adapaters_common_hermes.py index 2c6d004fa..51066860a 100644 --- a/tests/adapters/test_adapaters_common_hermes.py +++ b/tests/adapters/test_adapaters_common_hermes.py @@ -91,24 +91,26 @@ def test_selected_model_config( assert hermes_common.selected_model_config(payload) == expected -@pytest.mark.parametrize("provider", [None, "relay"]) +@pytest.mark.parametrize("providers", [None, {"relay": {}}]) def test_validate_hermes_telemetry_provider_accepts_relay( - provider: str | None, + providers: dict[str, object] | None, ): - telemetry = {"enabled": True} - if provider is not None: - telemetry["provider"] = provider - payload = {"effective_config": {"config": {"telemetry": telemetry}}} + payload = {} + if providers is not None: + payload["telemetry_plan"] = {"providers": list(providers)} hermes_common.validate_hermes_telemetry_provider(payload) def test_validate_hermes_telemetry_provider_rejects_native(): - payload = { - "effective_config": { - "config": {"telemetry": {"enabled": True, "provider": "native"}} - } - } + payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} + + with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + hermes_common.validate_hermes_telemetry_provider(payload) + + +def test_validate_hermes_telemetry_provider_rejects_mixed_native_and_relay(): + payload = {"telemetry_plan": {"providers": ["relay", "native"], "relay_enabled": True}} with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): hermes_common.validate_hermes_telemetry_provider(payload) @@ -273,12 +275,8 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( } assert config["platform_toolsets"] == {"cli": ["git", "shell"]} assert config["plugins"]["enabled"] == ["observability/nemo_relay"] - assert observability["atof"]["output_directory"] == str( - tmp_path / "relay" / "atof" / "runtime-matrix" - ) - assert observability["atif"]["output_directory"] == str( - tmp_path / "relay" / "atif" / "runtime-matrix" - ) + assert observability["atof"]["output_directory"] == str(tmp_path / "relay" / "atof" / "runtime-matrix") + assert observability["atif"]["output_directory"] == str(tmp_path / "relay" / "atif" / "runtime-matrix") assert observability["atif"]["agent_name"] == "matrix-agent" assert observability["atif"]["model_name"] == "nvidia/review-model" @@ -415,9 +413,9 @@ def test_configure_hermes_relay_sets_hermes_plugin_environment( ), encoding="utf-8", ) - os.environ["FABRIC_RELAY_ENABLED"] = "true" os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(config_path) payload = { + "telemetry_plan": {"providers": ["relay"], "relay_enabled": True}, "runtime_context": {"runtime_id": "runtime-relay"}, "effective_config": { "agent_name": "review-agent", @@ -426,22 +424,18 @@ def test_configure_hermes_relay_sets_hermes_plugin_environment( "harness": {"settings": {"model": "review"}}, "models": {"review": {"model": "nvidia/review-model"}}, }, - } + }, } plugin_config = hermes_common.configure_hermes_relay(payload) assert plugin_config is not None assert os.environ["HERMES_NEMO_RELAY_ATOF_ENABLED"] == "1" - assert os.environ["HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY"] == str( - tmp_path / "atof" / "runtime-relay" - ) + assert os.environ["HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY"] == str(tmp_path / "atof" / "runtime-relay") assert os.environ["HERMES_NEMO_RELAY_ATOF_FILENAME"] == "custom.atof.jsonl" assert os.environ["HERMES_NEMO_RELAY_ATOF_MODE"] == "append" assert os.environ["HERMES_NEMO_RELAY_ATIF_ENABLED"] == "1" - assert os.environ["HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY"] == str( - tmp_path / "atif" / "runtime-relay" - ) + assert os.environ["HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY"] == str(tmp_path / "atif" / "runtime-relay") assert os.environ["HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE"] == "trace-{session_id}.atif.json" assert os.environ["HERMES_NEMO_RELAY_ATIF_AGENT_NAME"] == "review-agent" assert os.environ["HERMES_NEMO_RELAY_ATIF_AGENT_VERSION"] == "1.2.3" @@ -449,6 +443,4 @@ def test_configure_hermes_relay_sets_hermes_plugin_environment( def test_configure_hermes_relay_returns_none_when_disabled(): - os.environ.pop("FABRIC_RELAY_ENABLED", None) - assert hermes_common.configure_hermes_relay({}) is None diff --git a/tests/adapters/test_codex_cli.py b/tests/adapters/test_codex_cli.py index b4665fd20..286acec83 100644 --- a/tests/adapters/test_codex_cli.py +++ b/tests/adapters/test_codex_cli.py @@ -7,22 +7,15 @@ import subprocess import tomllib from pathlib import Path -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock +from unittest.mock import call import pytest -import yaml -from nemo_fabric import Fabric, FabricConfig +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig ROOT = Path(__file__).resolve().parents[2] -ADAPTER_PATH = ( - ROOT - / "adapters" - / "codex-cli" - / "src" - / "nemo_fabric_adapters" - / "codex_cli" - / "adapter.py" -) +ADAPTER_PATH = ROOT / "adapters" / "codex-cli" / "src" / "nemo_fabric_adapters" / "codex_cli" / "adapter.py" def load_codex_adapter(): @@ -179,9 +172,7 @@ def test_oneshot_command_uses_fabric_overrides_and_codex_owned_auth( assert "--dangerously-bypass-hook-trust" not in command assert ["--model", "gpt-5.4"] == command[-3:-1] assert command[-1] == "-" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { + assert tomllib.loads(codex_settings.codex_profile_path.read_text(encoding="utf-8")) == { "features": {"web_search": False}, "model_reasoning_effort": "high", } @@ -205,9 +196,7 @@ def test_configured_codex_profile_is_base_for_generated_profile(codex_payload): codex_settings = adapter.write_config_files(codex_payload) assert codex_settings.codex_profile_name == "fabric-runtime-1" - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { + assert tomllib.loads(codex_settings.codex_profile_path.read_text(encoding="utf-8")) == { "approval_policy": "never", "model_reasoning_effort": "high", "features": { @@ -244,9 +233,7 @@ def test_codex_home_uses_environment(tmp_path): adapter = load_codex_adapter() os.environ["CODEX_HOME"] = str(tmp_path / "custom-codex-home") - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) + name, path = adapter.get_codex_profile_path({"runtime_context": {"runtime_id": "runtime-1"}}) assert name == "fabric-runtime-1" assert path == tmp_path / "custom-codex-home" / "fabric-runtime-1.config.toml" @@ -256,9 +243,7 @@ def test_codex_home_defaults_to_user_codex_directory(): adapter = load_codex_adapter() os.environ.pop("CODEX_HOME", None) - name, path = adapter.get_codex_profile_path( - {"runtime_context": {"runtime_id": "runtime-1"}} - ) + name, path = adapter.get_codex_profile_path({"runtime_context": {"runtime_id": "runtime-1"}}) assert name == "fabric-runtime-1" assert path == Path.home() / ".codex" / "fabric-runtime-1.config.toml" @@ -270,15 +255,16 @@ def test_relay_routes_codex_through_standalone_gateway( tmp_path, ): adapter = load_codex_adapter() - os.environ["FABRIC_RELAY_ENABLED"] = "true" + codex_payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + } mock_find_port = MagicMock(return_value=43210) monkeypatch.setattr(adapter, "find_available_tcp_port", mock_find_port) relay_plugin_config = {"version": 1, "components": []} relay_config_path = tmp_path / "relay-config" / "config.toml" mock_load_config = MagicMock(return_value=relay_plugin_config) - mock_write_config = MagicMock( - return_value=(relay_config_path, tmp_path / "plugins.toml") - ) + mock_write_config = MagicMock(return_value=(relay_config_path, tmp_path / "plugins.toml")) monkeypatch.setattr( adapter.common_utils, "load_relay_plugin_config", @@ -303,9 +289,7 @@ def test_relay_routes_codex_through_standalone_gateway( assert "--config" not in command assert command[command.index("--profile") + 1] == "fabric-runtime-1" - config = tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) + config = tomllib.loads(codex_settings.codex_profile_path.read_text(encoding="utf-8")) assert config["model_provider"] == "nemo-relay-openai" assert config["model_providers"]["nemo-relay-openai"] == { "name": "NeMo Relay OpenAI", @@ -329,14 +313,34 @@ def test_relay_routes_codex_through_standalone_gateway( ) -def test_native_otel_profile_writes_codex_telemetry_config(codex_payload, tmp_path): - from examples.code_review_agent import codex_cli_config, with_native_otel +@pytest.mark.parametrize( + ("transport", "expected_exporter", "expected_protocol"), + [ + ("http_binary", "otlp-http", "binary"), + ("grpc", "otlp-grpc", "grpc"), + ], +) +def test_native_otel_profile_writes_codex_telemetry_config( + codex_payload, + tmp_path, + transport, + expected_exporter, + expected_protocol, +): + from examples.code_review_agent import codex_cli_config + from examples.code_review_agent import with_native_otel adapter = load_codex_adapter() config = codex_payload["effective_config"]["config"] typed = with_native_otel(codex_cli_config()) assert typed.telemetry is not None - config["telemetry"] = typed.telemetry.to_mapping() + native_config = typed.telemetry.to_mapping()["providers"]["native"]["config"] + native_config["components"][0]["config"]["opentelemetry"]["transport"] = transport + codex_payload["telemetry_plan"] = { + "providers": ["native"], + "relay_enabled": False, + "native_config": native_config, + } config["harness"]["settings"]["config_overrides"] = {} codex_settings = adapter.write_config_files(codex_payload) @@ -347,15 +351,13 @@ def test_native_otel_profile_writes_codex_telemetry_config(codex_payload, tmp_pa assert command[command.index("--profile") + 1] == "fabric-runtime-1" assert "--dangerously-bypass-hook-trust" not in command - assert tomllib.loads( - codex_settings.codex_profile_path.read_text(encoding="utf-8") - ) == { + assert tomllib.loads(codex_settings.codex_profile_path.read_text(encoding="utf-8")) == { "otel": { "environment": "dev", "trace_exporter": { - "otlp-http": { + expected_exporter: { "endpoint": "http://localhost:4318/v1/traces", - "protocol": "binary", + "protocol": expected_protocol, } }, } @@ -435,7 +437,7 @@ def test_start_relay_gateway_waits_for_health_and_starts_process_group( adapter = load_codex_adapter() relay_config_path = tmp_path / "relay-config" / "config.toml" relay_config_path.parent.mkdir() - relay_config_path.write_text("[agents.codex]\ncommand = \"codex\"\n", encoding="utf-8") + relay_config_path.write_text('[agents.codex]\ncommand = "codex"\n', encoding="utf-8") mock_process = MagicMock() mock_popen = MagicMock(return_value=mock_process) mock_wait = MagicMock() @@ -555,9 +557,8 @@ def test_runtime_reuses_codex_thread_across_invocations(codex_payload, monkeypat monkeypatch.setattr(adapter.subprocess, "run", mock_run) os.environ.pop("OPENAI_API_KEY", None) os.environ["CODEX_HOME"] = str(tmp_path / "codex-home") - codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = { - "CODEX_EXPLICIT": "forward-me" - } + os.environ["FABRIC_UNRELATED_SECRET"] = "do-not-forward" + codex_payload["effective_config"]["config"]["harness"]["settings"]["env"] = {"CODEX_EXPLICIT": "forward-me"} first = adapter.run_codex(codex_payload) codex_payload["runtime_context"]["invocation_id"] = "invocation-2" @@ -620,9 +621,7 @@ def test_runtime_persists_codex_thread_state(codex_payload, monkeypatch): def test_adapter_rejects_structured_input_until_chat_is_supported(codex_payload): adapter = load_codex_adapter() - codex_payload["request"]["input"] = { - "messages": [{"role": "user", "content": "Inspect the change."}] - } + codex_payload["request"]["input"] = {"messages": [{"role": "user", "content": "Inspect the change."}]} with pytest.raises(ValueError, match="requires text input"): adapter.request_to_prompt(codex_payload) @@ -649,9 +648,7 @@ def test_adapter_rejects_non_mapping_env(codex_payload, env): ), ], ) -def test_process_launch_failures_return_structured_results( - codex_payload, monkeypatch, error, message, returncode -): +def test_process_launch_failures_return_structured_results(codex_payload, monkeypatch, error, message, returncode): adapter = load_codex_adapter() monkeypatch.setattr(adapter.subprocess, "run", MagicMock(side_effect=error)) @@ -696,9 +693,7 @@ def test_adapter_rejects_invalid_timeout(codex_payload, timeout): adapter.run_codex(codex_payload) -def test_runtime_fails_if_codex_does_not_return_thread_identity( - codex_payload, monkeypatch -): +def test_runtime_fails_if_codex_does_not_return_thread_identity(codex_payload, monkeypatch): adapter = load_codex_adapter() mock_run = MagicMock( return_value=subprocess.CompletedProcess( @@ -779,17 +774,15 @@ async def test_fabric_oneshot_uses_cached_codex_auth(tmp_path): ) assert report.status == "pass" - assert any( - check.name == "requirement.binary" and "codex_command" in check.message - for check in report.checks - ) + assert any(check.name == "requirement.binary" and "codex_command" in check.message for check in report.checks) assert not any(check.name == "requirement.env" for check in report.checks) assert result.output["response"] == "thread-fake:inspect" assert "--ephemeral" not in result.output["command"] def test_codex_profile_resolves_runtime_adapter(): - from examples.code_review_agent import BASE_DIR, codex_cli_config + from examples.code_review_agent import BASE_DIR + from examples.code_review_agent import codex_cli_config plan = Fabric().plan( codex_cli_config(), diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index cf0ef2408..1aee5dd03 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -16,7 +16,8 @@ import types from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from unittest.mock import MagicMock import pytest @@ -132,7 +133,6 @@ def make(tmp_path: Path, *, runtime_id: str = "run-1") -> dict[str, Any]: "api_key_env": "NVIDIA_API_KEY", } }, - "telemetry": {"enabled": False}, }, }, "runtime_context": { @@ -278,9 +278,18 @@ async def test_relay_telemetry_wraps_agent_and_reports_artifacts( ) monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) monkeypatch.setattr(adapter.common_utils, "collect_relay_artifacts", lambda _c: artifacts) - monkeypatch.setenv("FABRIC_RELAY_ENABLED", "true") + payload = make_payload(tmp_path) + payload["telemetry_plan"] = { + "providers": ["relay"], + "relay_enabled": True, + "relay_project": None, + "relay_output_dir": None, + "relay_config": {}, + "native_config": None, + "adapter_outputs": [], + } - output = await adapter.run_deepagents(make_payload(tmp_path)) + output = await adapter.run_deepagents(payload) assert fake_relay["wrapped"] assert fake_relay["plugin_open"] @@ -300,10 +309,13 @@ async def test_native_telemetry_exports_without_artifacts( monkeypatch.setattr(adapter.common_utils, "relay_api_plugin_config", lambda _c: object()) payload = make_payload(tmp_path) - payload["effective_config"]["config"]["telemetry"] = { - "enabled": True, - "provider": "native", - "config": { + payload["telemetry_plan"] = { + "providers": ["native"], + "relay_enabled": False, + "relay_project": None, + "relay_output_dir": None, + "relay_config": None, + "native_config": { "version": 1, "components": [ { @@ -319,6 +331,7 @@ async def test_native_telemetry_exports_without_artifacts( } ], }, + "adapter_outputs": [], } output = await adapter.run_deepagents(payload) @@ -457,7 +470,10 @@ async def test_real_langgraph_async_checkpointer(tmp_path, make_payload, monkeyp import deepagents from langchain_core.messages import AIMessage - from langgraph.graph import END, START, MessagesState, StateGraph + from langgraph.graph import END + from langgraph.graph import START + from langgraph.graph import MessagesState + from langgraph.graph import StateGraph def respond(_state): return {"messages": [AIMessage(content="ok")]} diff --git a/tests/adapters/test_hermes_cli.py b/tests/adapters/test_hermes_cli.py index 30901ce66..1cd1d1f15 100644 --- a/tests/adapters/test_hermes_cli.py +++ b/tests/adapters/test_hermes_cli.py @@ -51,9 +51,8 @@ async def test_hermes_cli_rejects_native_telemetry( """schema_version: fabric.profile/v1alpha1 name: native_telemetry telemetry: - enabled: true - provider: native - config: {} + providers: + native: {} """, encoding="utf-8", ) @@ -93,7 +92,7 @@ async def test_hermes_cli_multi_turn( session_db = hermes_state.SessionDB(db_path=session_db_path) session = session_db.get_session_by_title(runtime_id) assert session is not None - assert session['id'] == runtime_id - assert session['model'] == 'test-model' - assert session['source'] == 'fabric' - assert session['title'] == runtime_id + assert session["id"] == runtime_id + assert session["model"] == "test-model" + assert session["source"] == "fabric" + assert session["title"] == runtime_id diff --git a/tests/adapters/test_hermes_sdk_adapter.py b/tests/adapters/test_hermes_sdk_adapter.py index ae1b62ebe..eca0b643d 100644 --- a/tests/adapters/test_hermes_sdk_adapter.py +++ b/tests/adapters/test_hermes_sdk_adapter.py @@ -23,9 +23,17 @@ async def test_hermes_sdk_rejects_native_telemetry(): payload = { - "effective_config": { - "config": {"telemetry": {"enabled": True, "provider": "native"}} - } + "effective_config": {"config": {}}, + "runtime_context": {"runtime_id": "runtime-1", "invocation_id": "invocation-1"}, + "telemetry_plan": { + "providers": ["native"], + "relay_enabled": False, + "relay_project": None, + "relay_output_dir": None, + "relay_config": None, + "native_config": {}, + "adapter_outputs": [], + }, } with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): diff --git a/tests/e2e/test_cli.py b/tests/e2e/test_cli.py index 2a5e33d54..5526e4065 100644 --- a/tests/e2e/test_cli.py +++ b/tests/e2e/test_cli.py @@ -9,7 +9,8 @@ import subprocess from pathlib import Path -from _utils.utils import assert_relay_disabled_native_observability, run_fabric_cli +from _utils.utils import assert_relay_disabled_native_observability +from _utils.utils import run_fabric_cli def test_cli( @@ -76,12 +77,12 @@ def test_cli( assert "mode" not in profile_plan["config"]["runtime"] assert profile_plan["capability_plan"]["native"]["skill_paths"] assert "github" in profile_plan["capability_plan"]["native"]["mcp_servers"] - telemetry_plan = profile_plan["telemetry_plan"] - assert telemetry_plan["relay_enabled"] is relay_enabled if relay_enabled: + telemetry_plan = profile_plan["telemetry_plan"] + assert telemetry_plan["relay_enabled"] is True assert telemetry_plan["relay_output_dir"] else: - assert not telemetry_plan.get("relay_output_dir") + assert profile_plan.get("telemetry_plan") is None multi_plan = call_json( "plan", diff --git a/tests/fixtures/file-config-agent/agent.yaml b/tests/fixtures/file-config-agent/agent.yaml index 9e9f640e7..2124b3a61 100644 --- a/tests/fixtures/file-config-agent/agent.yaml +++ b/tests/fixtures/file-config-agent/agent.yaml @@ -41,8 +41,7 @@ mcp: url: ${GITHUB_MCP_URL} exposure: harness_native -telemetry: - enabled: false +telemetry: null profiles: directories: diff --git a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml b/tests/fixtures/file-config-agent/profiles/codex-cli.yaml index 8bf2848ee..4b7582de4 100644 --- a/tests/fixtures/file-config-agent/profiles/codex-cli.yaml +++ b/tests/fixtures/file-config-agent/profiles/codex-cli.yaml @@ -34,5 +34,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/codex-cli -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/file-config-agent/profiles/env-local.yaml b/tests/fixtures/file-config-agent/profiles/env-local.yaml index 809940f88..fe770a7b6 100644 --- a/tests/fixtures/file-config-agent/profiles/env-local.yaml +++ b/tests/fixtures/file-config-agent/profiles/env-local.yaml @@ -10,5 +10,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/local -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/file-config-agent/profiles/env-opensandbox.yaml b/tests/fixtures/file-config-agent/profiles/env-opensandbox.yaml index 51ee15188..d0603fd67 100644 --- a/tests/fixtures/file-config-agent/profiles/env-opensandbox.yaml +++ b/tests/fixtures/file-config-agent/profiles/env-opensandbox.yaml @@ -15,15 +15,12 @@ environment: image: nvcr.io/nvidia/nemo/fabric-hermes:latest telemetry: - enabled: true + providers: + relay: {} + +relay: output_dir: ./artifacts/opensandbox - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - atif: - enabled: true - output_directory: ./artifacts/opensandbox + observability: + atif: + enabled: true + output_directory: ./artifacts/opensandbox diff --git a/tests/fixtures/file-config-agent/profiles/hermes-cli.yaml b/tests/fixtures/file-config-agent/profiles/hermes-cli.yaml index b5ccbf8f5..91c20725d 100644 --- a/tests/fixtures/file-config-agent/profiles/hermes-cli.yaml +++ b/tests/fixtures/file-config-agent/profiles/hermes-cli.yaml @@ -26,5 +26,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/hermes-cli -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/file-config-agent/profiles/hermes-sdk.yaml b/tests/fixtures/file-config-agent/profiles/hermes-sdk.yaml index 814cab828..571aaf587 100644 --- a/tests/fixtures/file-config-agent/profiles/hermes-sdk.yaml +++ b/tests/fixtures/file-config-agent/profiles/hermes-sdk.yaml @@ -30,5 +30,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/hermes-sdk -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/file-config-agent/profiles/mcp-github.yaml b/tests/fixtures/file-config-agent/profiles/mcp-github.yaml index 20d6f8245..b45bbb80b 100644 --- a/tests/fixtures/file-config-agent/profiles/mcp-github.yaml +++ b/tests/fixtures/file-config-agent/profiles/mcp-github.yaml @@ -13,21 +13,18 @@ mcp: exposure: fabric_managed telemetry: - enabled: true + providers: + relay: {} + +relay: output_dir: ./artifacts/mcp-github - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - atif: - enabled: true - output_directory: ./artifacts/mcp-github - filename_template: "trajectory-{session_id}.atif.json" - atof: - enabled: true - output_directory: ./artifacts/mcp-github - filename: events.atof.jsonl - mode: overwrite + observability: + atif: + enabled: true + output_directory: ./artifacts/mcp-github + filename_template: "trajectory-{session_id}.atif.json" + atof: + enabled: true + output_directory: ./artifacts/mcp-github + filename: events.atof.jsonl + mode: overwrite diff --git a/tests/fixtures/file-config-agent/profiles/native-otel.yaml b/tests/fixtures/file-config-agent/profiles/native-otel.yaml index f943b4d9b..e49a1ee3a 100644 --- a/tests/fixtures/file-config-agent/profiles/native-otel.yaml +++ b/tests/fixtures/file-config-agent/profiles/native-otel.yaml @@ -6,18 +6,18 @@ name: native_otel description: Test native telemetry file-profile resolution. telemetry: - enabled: true - provider: native - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - opentelemetry: + providers: + native: + config: + version: 1 + components: + - kind: observability enabled: true - transport: http_binary - endpoint: http://localhost:4318/v1/traces - resource_attributes: - deployment.environment: dev + config: + version: 1 + opentelemetry: + enabled: true + transport: http_binary + endpoint: http://localhost:4318/v1/traces + resource_attributes: + deployment.environment: dev diff --git a/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml b/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml index d52878669..0191fcb9a 100644 --- a/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml +++ b/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml @@ -6,28 +6,24 @@ name: relay_openinference description: Test Relay OpenInference file-profile resolution. telemetry: - enabled: true - provider: relay + providers: + relay: {} + +relay: output_dir: ./artifacts/relay-openinference - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - atif: - enabled: true - output_directory: ./artifacts/relay-openinference - filename_template: "trajectory-{session_id}.atif.json" - agent_name: code-review-agent - agent_version: fabric-poc - atof: - enabled: true - output_directory: ./artifacts/relay-openinference - filename: events.atof.jsonl - mode: overwrite - openinference: - enabled: true - transport: http_binary - endpoint: http://localhost:6006/v1/traces + observability: + atif: + enabled: true + output_directory: ./artifacts/relay-openinference + filename_template: "trajectory-{session_id}.atif.json" + agent_name: code-review-agent + agent_version: fabric-poc + atof: + enabled: true + output_directory: ./artifacts/relay-openinference + filename: events.atof.jsonl + mode: overwrite + openinference: + enabled: true + transport: http_binary + endpoint: http://localhost:6006/v1/traces diff --git a/tests/fixtures/file-config-agent/profiles/relay-otel.yaml b/tests/fixtures/file-config-agent/profiles/relay-otel.yaml index 8125199f7..662f7d48e 100644 --- a/tests/fixtures/file-config-agent/profiles/relay-otel.yaml +++ b/tests/fixtures/file-config-agent/profiles/relay-otel.yaml @@ -6,24 +6,20 @@ name: relay_otel description: Test Relay OpenTelemetry file-profile resolution. telemetry: - enabled: true - provider: relay + providers: + relay: {} + +relay: output_dir: ./artifacts/relay-otel - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - opentelemetry: - enabled: true - transport: http_binary - endpoint: http://localhost:4318/v1/traces - service_name: code-review-agent - service_namespace: fabric - service_version: fabric-poc - instrumentation_scope: nemo-relay-otel - timeout_millis: 3000 - resource_attributes: - deployment.environment: dev + observability: + opentelemetry: + enabled: true + transport: http_binary + endpoint: http://localhost:4318/v1/traces + service_name: code-review-agent + service_namespace: fabric + service_version: fabric-poc + instrumentation_scope: nemo-relay-otel + timeout_millis: 3000 + resource_attributes: + deployment.environment: dev diff --git a/tests/fixtures/file-config-agent/profiles/relay.yaml b/tests/fixtures/file-config-agent/profiles/relay.yaml index c28f8a78d..9550bbd80 100644 --- a/tests/fixtures/file-config-agent/profiles/relay.yaml +++ b/tests/fixtures/file-config-agent/profiles/relay.yaml @@ -6,24 +6,20 @@ name: relay description: Test Relay ATOF/ATIF file-profile resolution. telemetry: - enabled: true - provider: relay + providers: + relay: {} + +relay: output_dir: ./artifacts/relay - config: - version: 1 - components: - - kind: observability - enabled: true - config: - version: 1 - atif: - enabled: true - output_directory: ./artifacts/relay - filename_template: "trajectory-{session_id}.atif.json" - agent_name: code-review-agent - agent_version: fabric-poc - atof: - enabled: true - output_directory: ./artifacts/relay - filename: events.atof.jsonl - mode: overwrite + observability: + atif: + enabled: true + output_directory: ./artifacts/relay + filename_template: "trajectory-{session_id}.atif.json" + agent_name: code-review-agent + agent_version: fabric-poc + atof: + enabled: true + output_directory: ./artifacts/relay + filename: events.atof.jsonl + mode: overwrite diff --git a/tests/fixtures/hermes-cli-agent/agent.yaml b/tests/fixtures/hermes-cli-agent/agent.yaml index a7fb5d9af..595b76046 100644 --- a/tests/fixtures/hermes-cli-agent/agent.yaml +++ b/tests/fixtures/hermes-cli-agent/agent.yaml @@ -44,8 +44,7 @@ mcp: url: ${GITHUB_MCP_URL} exposure: harness_native -telemetry: - enabled: false +telemetry: null profiles: directories: diff --git a/tests/fixtures/hermes-cli-agent/profiles/env-local.yaml b/tests/fixtures/hermes-cli-agent/profiles/env-local.yaml index 20c58f1b5..4b7c9eec3 100644 --- a/tests/fixtures/hermes-cli-agent/profiles/env-local.yaml +++ b/tests/fixtures/hermes-cli-agent/profiles/env-local.yaml @@ -10,5 +10,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/hermes-cli -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/hermes-shim-agent/agent.yaml b/tests/fixtures/hermes-shim-agent/agent.yaml index 85e735d18..92b17d861 100644 --- a/tests/fixtures/hermes-shim-agent/agent.yaml +++ b/tests/fixtures/hermes-shim-agent/agent.yaml @@ -40,8 +40,7 @@ mcp: url: ${GITHUB_MCP_URL} exposure: harness_native -telemetry: - enabled: false +telemetry: null profiles: directories: diff --git a/tests/fixtures/hermes-shim-agent/profiles/env-local.yaml b/tests/fixtures/hermes-shim-agent/profiles/env-local.yaml index 9bc6a0776..3ad46b047 100644 --- a/tests/fixtures/hermes-shim-agent/profiles/env-local.yaml +++ b/tests/fixtures/hermes-shim-agent/profiles/env-local.yaml @@ -10,5 +10,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/local -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/hermes-shim-agent/profiles/harbor-swebench-django-13741.yaml b/tests/fixtures/hermes-shim-agent/profiles/harbor-swebench-django-13741.yaml index e09880b11..ea1d60aac 100644 --- a/tests/fixtures/hermes-shim-agent/profiles/harbor-swebench-django-13741.yaml +++ b/tests/fixtures/hermes-shim-agent/profiles/harbor-swebench-django-13741.yaml @@ -28,5 +28,4 @@ environment: source: harbor_swebench instance_id: django__django-13741 -telemetry: - enabled: false +telemetry: null diff --git a/tests/fixtures/hermes-shim-agent/profiles/mcp-github.yaml b/tests/fixtures/hermes-shim-agent/profiles/mcp-github.yaml index 4009fc365..a5e9896c7 100644 --- a/tests/fixtures/hermes-shim-agent/profiles/mcp-github.yaml +++ b/tests/fixtures/hermes-shim-agent/profiles/mcp-github.yaml @@ -13,5 +13,8 @@ mcp: exposure: harness_native telemetry: - enabled: true + providers: + relay: {} + +relay: output_dir: ./artifacts/relay diff --git a/tests/fixtures/hermes-shim-agent/profiles/swebench-shim.yaml b/tests/fixtures/hermes-shim-agent/profiles/swebench-shim.yaml index bb6eb51d2..850a5661d 100644 --- a/tests/fixtures/hermes-shim-agent/profiles/swebench-shim.yaml +++ b/tests/fixtures/hermes-shim-agent/profiles/swebench-shim.yaml @@ -27,5 +27,4 @@ environment: workspace: ./repos/my-service artifacts: ./artifacts/swebench -telemetry: - enabled: false +telemetry: null diff --git a/tests/integrations/test_harbor_runner.py b/tests/integrations/test_harbor_runner.py index a31d10d50..793f2c678 100644 --- a/tests/integrations/test_harbor_runner.py +++ b/tests/integrations/test_harbor_runner.py @@ -19,32 +19,10 @@ DEMO_HOST_GATEWAY = DEMO_ROOT / "host-gateway.compose.yaml" DEMO_SOLUTION = DEMO_ROOT / "task" / "solution" / "solve.sh" DEMO_CONFIGS = DEMO_ROOT / "task" / "environment" / "fabric" / "configs" -CODEX_CONFIG = ( - DEMO_ROOT - / "task" - / "environment" - / "fabric" - / "configs" - / "codex.yaml" -) -RELAY_CONFIG = ( - DEMO_ROOT - / "task" - / "environment" - / "fabric" - / "configs" - / "hermes-relay.yaml" -) +CODEX_CONFIG = DEMO_ROOT / "task" / "environment" / "fabric" / "configs" / "codex.yaml" +RELAY_CONFIG = DEMO_ROOT / "task" / "environment" / "fabric" / "configs" / "hermes-relay.yaml" INTEGRATION_README = ROOT / "examples" / "harbor" / "README.md" -SDK_INTEGRATION_README = ( - ROOT - / "python" - / "src" - / "nemo_fabric" - / "integrations" - / "harbor" - / "README.md" -) +SDK_INTEGRATION_README = ROOT / "python" / "src" / "nemo_fabric" / "integrations" / "harbor" / "README.md" HARBOR_PACKAGE_INIT = SDK_INTEGRATION_README.parent / "__init__.py" @@ -59,8 +37,10 @@ def load_codex_adapter(): def test_runner_composes_harbor_values_on_an_independent_config(tmp_path): from nemo_fabric import RunRequest - from nemo_fabric.integrations.harbor.models import HarborMcpServer, HarborRunSpec - from nemo_fabric.integrations.harbor.runner import compose_config, load_config + from nemo_fabric.integrations.harbor.models import HarborMcpServer + from nemo_fabric.integrations.harbor.models import HarborRunSpec + from nemo_fabric.integrations.harbor.runner import compose_config + from nemo_fabric.integrations.harbor.runner import load_config config_path = tmp_path / "agent.yaml" config_path.write_text( @@ -122,13 +102,12 @@ def test_runner_composes_harbor_values_on_an_independent_config(tmp_path): assert config.mcp.servers["local"].extra_fields["args"] == ["--stdio"] assert config.skills is not None assert config.skills.paths == [str(tmp_path / "skills")] - assert json.loads(json.dumps(config.to_mapping()))["metadata"]["name"] == ( - "harbor-demo" - ) + assert json.loads(json.dumps(config.to_mapping()))["metadata"]["name"] == ("harbor-demo") def test_runner_preserves_config_capabilities_without_harbor_replacements(tmp_path): - from nemo_fabric import FabricConfig, RunRequest + from nemo_fabric import FabricConfig + from nemo_fabric import RunRequest from nemo_fabric.integrations.harbor.models import HarborRunSpec from nemo_fabric.integrations.harbor.runner import compose_config @@ -176,10 +155,10 @@ def test_runner_rejects_malformed_config(tmp_path): def test_harbor_transport_models_validate_mcp_targets(): - from pydantic import ValidationError - from nemo_fabric import RunRequest - from nemo_fabric.integrations.harbor.models import HarborMcpServer, HarborRunSpec + from nemo_fabric.integrations.harbor.models import HarborMcpServer + from nemo_fabric.integrations.harbor.models import HarborRunSpec + from pydantic import ValidationError spec = HarborRunSpec.model_validate_json( HarborRunSpec( @@ -328,9 +307,7 @@ def test_codex_adapter_maps_fabric_request_to_cli(tmp_path): "--skip-git-repo-check", "-", ] - assert tomllib.loads(profile_path.read_text(encoding="utf-8")) == { - "model_reasoning_effort": "high" - } + assert tomllib.loads(profile_path.read_text(encoding="utf-8")) == {"model_reasoning_effort": "high"} assert adapter.resolve_cwd(payload) == tmp_path @@ -344,7 +321,7 @@ def test_codex_demo_uses_current_adapter_contract(): assert settings["skip_git_repo_check"] is True assert settings["config_overrides"]["model_reasoning_effort"] == "high" dockerfile = DEMO_DOCKERFILE.read_text(encoding="utf-8") - assert 'nemo-fabric[codex,harbor,hermes,relay,runtime]' in dockerfile + assert "nemo-fabric[codex,harbor,hermes,relay,runtime]" in dockerfile assert "@openai/codex@0.142.4" in dockerfile @@ -366,9 +343,11 @@ def test_harbor_demo_uses_complete_configs_without_profiles(): def test_harbor_smoke_config_resolves_its_local_adapter(): - from nemo_fabric import Fabric, RunRequest + from nemo_fabric import Fabric + from nemo_fabric import RunRequest from nemo_fabric.integrations.harbor.models import HarborRunSpec - from nemo_fabric.integrations.harbor.runner import compose_config, load_config + from nemo_fabric.integrations.harbor.runner import compose_config + from nemo_fabric.integrations.harbor.runner import load_config config_path = DEMO_CONFIGS / "smoke.yaml" spec = HarborRunSpec(config_path=config_path, request=RunRequest(input="fix it")) @@ -416,7 +395,8 @@ def test_harbor_demo_setup_and_solution_fail_fast(): def test_harbor_telemetry_demo_exports_phoenix_atof_and_atif(): config = yaml.safe_load(RELAY_CONFIG.read_text(encoding="utf-8")) host_gateway = yaml.safe_load(DEMO_HOST_GATEWAY.read_text(encoding="utf-8")) - observability = config["telemetry"]["config"]["components"][0]["config"] + assert "relay" in config["telemetry"]["providers"] + observability = config["relay"]["observability"] demo = DEMO_README.read_text(encoding="utf-8") assert observability["openinference"] == { diff --git a/tests/python/test_code_review_example.py b/tests/python/test_code_review_example.py index ab3a911a7..063127d1a 100644 --- a/tests/python/test_code_review_example.py +++ b/tests/python/test_code_review_example.py @@ -6,23 +6,24 @@ import json import subprocess import sys -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from examples.code_review_agent import BASE_DIR from examples.code_review_agent import __main__ as main_module -from examples.code_review_agent import ( - BASE_DIR, - base_config, - codex_cli_config, - hermes_cli_config, - hermes_sdk_config, - with_fabric_managed_github_mcp, - with_native_otel, - with_opensandbox, - with_relay, - with_relay_openinference, - with_relay_otel, -) -from nemo_fabric import Fabric, FabricConfig, RunOutput +from examples.code_review_agent import base_config +from examples.code_review_agent import codex_cli_config +from examples.code_review_agent import hermes_cli_config +from examples.code_review_agent import hermes_sdk_config +from examples.code_review_agent import with_fabric_managed_github_mcp +from examples.code_review_agent import with_native_otel +from examples.code_review_agent import with_opensandbox +from examples.code_review_agent import with_relay +from examples.code_review_agent import with_relay_openinference +from examples.code_review_agent import with_relay_otel +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig +from nemo_fabric import RunOutput def test_variant_builders_return_independent_complete_configs(): @@ -59,7 +60,7 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): ) assert base.telemetry is not None - assert base.telemetry.enabled is False + assert base.telemetry.providers == {} assert base.environment is not None assert base.environment.provider == "local" assert base.mcp is not None @@ -68,11 +69,11 @@ def test_capability_and_telemetry_variants_do_not_mutate_their_input(): assert variants[0].mcp is not None assert variants[0].mcp.servers["github"].exposure == "fabric_managed" assert variants[1].telemetry is not None - assert variants[1].telemetry.provider == "native" + assert "native" in variants[1].telemetry.providers assert variants[2].environment is not None assert variants[2].environment.provider == "opensandbox" assert variants[3].telemetry is not None - assert variants[3].telemetry.provider == "relay" + assert "relay" in variants[3].telemetry.providers def test_variants_plan_without_file_profiles(): @@ -112,10 +113,12 @@ def test_example_entrypoint_plans_without_starting_a_runtime(): plan = json.loads(completed.stdout) assert plan["agent_name"] == "code-review-agent" assert plan["profiles"] == [] - assert ( - plan["adapter_descriptor"]["descriptor"]["adapter_id"] == adapter_id - ) - assert plan["telemetry_plan"]["relay_enabled"] is relay_enabled + assert plan["adapter_descriptor"]["descriptor"]["adapter_id"] == adapter_id + telemetry_plan = plan.get("telemetry_plan") + if relay_enabled: + assert telemetry_plan["relay_enabled"] is True + else: + assert telemetry_plan is None async def test_example_entrypoint_shows_response_after_normalized_output( diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index fe29aba20..93829c7c6 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -11,8 +11,12 @@ import nemo_fabric._native as native import pytest -from examples.code_review_agent import BASE_DIR, base_config -from nemo_fabric import Fabric, FabricConfig, FabricProfileConfig, FabricRuntimeError +from examples.code_review_agent import BASE_DIR +from examples.code_review_agent import base_config +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig +from nemo_fabric import FabricProfileConfig +from nemo_fabric import FabricRuntimeError async def test_native_sdk(hermes_shim_agent_dir: Path): @@ -116,7 +120,7 @@ async def smoke(client: Fabric, fixture_agent: Path) -> None: } } }, - "telemetry": {"enabled": False}, + "telemetry": None, "consumer_extension": { "base": True, "nested": {"first": 1}, @@ -126,7 +130,8 @@ async def smoke(client: Fabric, fixture_agent: Path) -> None: typed_profile = FabricProfileConfig( name="typed_relay", harness={"settings": {"timeout_seconds": 30}}, - telemetry={"enabled": True, "output_dir": "./artifacts/relay"}, + telemetry={"providers": {"relay": {}}}, + relay={"output_dir": "./artifacts/relay"}, consumer_extension={ "profile": True, "nested": {"second": 2}, diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index 9019743a3..cc5e166b0 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -8,42 +8,45 @@ import json from inspect import signature from pathlib import Path -from typing import Any, get_overloads - -import pytest -from pydantic import ValidationError +from typing import Any +from typing import get_overloads import nemo_fabric import nemo_fabric.errors as fabric_errors - -from nemo_fabric import ( - AdapterInfo, - DoctorReport, - EffectiveConfig, - EnvironmentConfig, - Fabric, - FabricCapabilityError, - FabricConfig, - FabricConfigError, - FabricError, - FabricProfileConfig, - FabricNativeUnavailableError, - FabricRuntimeError, - FabricStateError, - HarnessConfig, - McpConfig, - MetadataConfig, - RunOutput, - RunPlan, - RunRequest, - RunResult, - RuntimeCapabilities, - RuntimeConfig, - Runtime, - RuntimeHandle, - SkillConfig, - TelemetryConfig, -) +import pytest +from nemo_fabric import AdapterInfo +from nemo_fabric import DoctorReport +from nemo_fabric import EffectiveConfig +from nemo_fabric import EnvironmentConfig +from nemo_fabric import Fabric +from nemo_fabric import FabricCapabilityError +from nemo_fabric import FabricConfig +from nemo_fabric import FabricConfigError +from nemo_fabric import FabricError +from nemo_fabric import FabricNativeUnavailableError +from nemo_fabric import FabricProfileConfig +from nemo_fabric import FabricRuntimeError +from nemo_fabric import FabricStateError +from nemo_fabric import HarnessConfig +from nemo_fabric import McpConfig +from nemo_fabric import MetadataConfig +from nemo_fabric import RelayAtifConfig +from nemo_fabric import RelayAtofConfig +from nemo_fabric import RelayComponentConfig +from nemo_fabric import RelayConfigPolicy +from nemo_fabric import RelayObservabilityConfig +from nemo_fabric import RunOutput +from nemo_fabric import RunPlan +from nemo_fabric import RunRequest +from nemo_fabric import RunResult +from nemo_fabric import Runtime +from nemo_fabric import RuntimeCapabilities +from nemo_fabric import RuntimeConfig +from nemo_fabric import RuntimeHandle +from nemo_fabric import SkillConfig +from nemo_fabric import TelemetryConfig +from nemo_fabric.types import _ResolvedFabricConfig +from pydantic import ValidationError def test_public_contract_has_no_unreleased_aliases(): @@ -137,7 +140,6 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): config.enable_relay( project="fabric-tests", output_dir="./artifacts/relay", - config={"version": 1}, ) assert isinstance(config.mcp, McpConfig) @@ -155,11 +157,12 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): } } assert config.to_mapping()["telemetry"] == { - "enabled": True, - "provider": "relay", + "providers": {"relay": {}}, + } + assert config.to_mapping()["relay"] == { "project": "fabric-tests", "output_dir": "./artifacts/relay", - "config": {"version": 1}, + "components": [], } config.remove_mcp_server("github").remove_mcp_server("missing") @@ -176,8 +179,80 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): url="http://example.invalid", exposure="sideways", ) - with pytest.raises(ValidationError, match="provider"): - TelemetryConfig(provider="sideways") + with pytest.raises(ValidationError, match="providers"): + TelemetryConfig(providers={"sideways": {}}) + + +def test_fabric_config_authors_first_class_relay_observability(): + config = _fabric_config() + + config.enable_relay( + output_dir="./artifacts/relay", + observability=RelayObservabilityConfig( + atof=RelayAtofConfig( + enabled=True, + output_directory="./artifacts/relay", + filename="events.atof.jsonl", + mode="overwrite", + ), + atif=RelayAtifConfig( + enabled=True, + output_directory="./artifacts/relay", + filename_template="trajectory-{session_id}.atif.json", + agent_name="fabric-tests", + ), + ), + components=[ + RelayComponentConfig(kind="switchyard", config={"route": "canary"}), + ], + policy=RelayConfigPolicy(unknown_component="error"), + ) + + assert config.to_mapping()["telemetry"] == { + "providers": {"relay": {}}, + } + assert config.to_mapping()["relay"] == { + "output_dir": "./artifacts/relay", + "observability": { + "version": 1, + "atof": { + "enabled": True, + "output_directory": "./artifacts/relay", + "filename": "events.atof.jsonl", + "mode": "overwrite", + }, + "atif": { + "enabled": True, + "agent_name": "fabric-tests", + "model_name": "unknown", + "output_directory": "./artifacts/relay", + "filename_template": "trajectory-{session_id}.atif.json", + }, + }, + "components": [ + { + "kind": "switchyard", + "enabled": True, + "config": {"route": "canary"}, + }, + ], + "policy": { + "unknown_component": "error", + "unknown_field": "warn", + "unsupported_value": "error", + }, + } + + +def test_telemetry_config_enable_native_preserves_existing_config(): + telemetry = TelemetryConfig() + + telemetry.enable_native(config={"components": [{"kind": "observability"}]}) + telemetry.enable_native() + + assert telemetry.to_mapping()["providers"]["native"]["config"] == { + "components": [{"kind": "observability"}], + } def test_config_emits_schema_shape_and_validates(): @@ -211,7 +286,12 @@ def test_config_emits_schema_shape_and_validates(): assert emitted["models"]["default"]["model"] == "test-model" assert emitted["skills"] == {"paths": ["./skills/review"]} assert emitted["mcp"]["servers"]["github"]["exposure"] == "fabric_managed" - assert emitted["telemetry"]["provider"] == "relay" + assert emitted["telemetry"]["providers"] == {"relay": {}} + assert emitted["relay"] == { + "project": "fabric-tests", + "output_dir": "./artifacts/relay", + "components": [], + } assert config.extra_fields == {"future_top_level": {"enabled": True}} normalized = FabricConfig.model_validate(config) @@ -359,6 +439,33 @@ def test_run_plan_requires_profiles(): RunPlan.from_mapping(raw) +def test_run_plan_config_enable_relay_preserves_existing_relay_fields(): + config = _ResolvedFabricConfig.from_mapping(_plan()["config"]) + + config.enable_relay( + output_dir="./artifacts/relay", + observability={"atif": {"enabled": True}}, + ) + config.enable_relay(policy={"unknown_component": "error"}) + + assert config.to_mapping()["relay"] == { + "output_dir": "./artifacts/relay", + "observability": {"atif": {"enabled": True}}, + "policy": {"unknown_component": "error"}, + } + + +def test_run_plan_config_enable_native_preserves_existing_native_config(): + config = _ResolvedFabricConfig.from_mapping(_plan()["config"]) + + config.telemetry.enable_native(config={"components": [{"kind": "observability"}]}) + config.telemetry.enable_native() + + assert config.to_mapping()["telemetry"]["providers"]["native"]["config"] == { + "components": [{"kind": "observability"}], + } + + def test_runtime_capabilities_reject_non_boolean_values(): with pytest.raises(FabricConfigError, match="streaming capability"): RuntimeCapabilities.from_mapping({"streaming": "false"}) @@ -500,9 +607,7 @@ def resolve_config( base_dir: str | None = None, ) -> str: assert json.loads(config_json)["metadata"]["name"] == "demo" - self.config_profile_calls.append( - None if profiles_json is None else json.loads(profiles_json) - ) + self.config_profile_calls.append(None if profiles_json is None else json.loads(profiles_json)) return json.dumps(_plan()["effective_config"]) def plan_config( @@ -512,18 +617,14 @@ def plan_config( base_dir: str | None = None, ) -> str: assert json.loads(config_json)["metadata"]["name"] == "demo" - self.config_profile_calls.append( - None if profiles_json is None else json.loads(profiles_json) - ) + self.config_profile_calls.append(None if profiles_json is None else json.loads(profiles_json)) return json.dumps(_plan()) def start_runtime(self, plan_json: str) -> str: assert json.loads(plan_json)["agent_name"] == "demo" return json.dumps(_runtime()) - def invoke_runtime( - self, plan_json: str, runtime_json: str, request_json: str - ) -> str: + def invoke_runtime(self, plan_json: str, runtime_json: str, request_json: str) -> str: if self.fail_invoke: raise RuntimeError("native invoke failed") request = json.loads(request_json) @@ -590,9 +691,7 @@ def test_run_request_is_validated_and_json_safe(): overrides["limits"]["turns"] = 2 assert request.request_id == "request-1" - assert request.to_mapping()["input"] == { - "messages": [{"role": "user", "content": "hello"}] - } + assert request.to_mapping()["input"] == {"messages": [{"role": "user", "content": "hello"}]} assert request.to_mapping()["context"] == {"run_id": "run-1", "labels": ["sdk"]} assert request.to_mapping()["overrides"] == { "temperature": 0, @@ -654,9 +753,7 @@ def test_run_request_preserves_extension_fields(): future_request={"enabled": True}, ) - assert request.to_mapping()["input"] == { - "messages": [{"role": "user", "content": "hello"}] - } + assert request.to_mapping()["input"] == {"messages": [{"role": "user", "content": "hello"}]} assert request.context == {"job_id": "job-1"} assert request.extra_fields["future_request"] == {"enabled": True} @@ -820,7 +917,7 @@ def test_run_result_preserves_native_telemetry_provider(): _run_result( telemetry={ "relay_enabled": False, - "metadata": {"telemetry_provider": "native"}, + "metadata": {"telemetry_providers": ["native"]}, }, ) ) @@ -949,6 +1046,7 @@ async def test_runtime_invoke_accepts_run_request(): }, } + async def test_runtime_handle_is_typed_and_detached(): runtime = Runtime( client=NativeClient(NativeRecorder()), diff --git a/tests/python/test_typed_config.py b/tests/python/test_typed_config.py index 5e2537369..f96ca783b 100644 --- a/tests/python/test_typed_config.py +++ b/tests/python/test_typed_config.py @@ -23,14 +23,11 @@ from shutil import copytree import yaml - -from nemo_fabric import ( - Fabric, - FabricConfig, - FabricProfileConfig, - RunRequest, - RunResult, -) +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig +from nemo_fabric import FabricProfileConfig +from nemo_fabric import RunRequest +from nemo_fabric import RunResult ROOT = Path(__file__).resolve().parents[2] COMMAND = ("cargo", "run", "-q", "-p", "fabric-cli", "--") @@ -67,7 +64,7 @@ def _repository_adapter_config() -> FabricConfig: "workspace": "./ws", "artifacts": "./artifacts/local", }, - "telemetry": {"enabled": False}, + "telemetry": None, } ) diff --git a/uv.lock b/uv.lock index 1c2a0a500..1675c5b83 100644 --- a/uv.lock +++ b/uv.lock @@ -2095,7 +2095,7 @@ requires-dist = [ { name = "nemo-fabric-adapters-hermes-sdk", marker = "extra == 'adapters-hermes-sdk'", editable = "adapters/hermes-sdk" }, { name = "nemo-fabric-adapters-hermes-sdk", marker = "extra == 'hermes'", editable = "adapters/hermes-sdk" }, { name = "nemo-fabric-runtime", marker = "extra == 'runtime'", editable = "python" }, - { name = "nemo-relay", marker = "extra == 'relay'", specifier = "~=0.4" }, + { name = "nemo-relay", marker = "extra == 'relay'", specifier = "~=0.5.0" }, { name = "pyyaml", marker = "extra == 'harbor'", specifier = ">=6.0" }, { name = "tomli-w", marker = "extra == 'relay'", specifier = "~=1.2" }, ] @@ -2188,7 +2188,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.2,<2.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0,<4.0" }, { name = "nemo-fabric-adapters-common", editable = "adapters/common" }, - { name = "nemo-relay", specifier = "~=0.4" }, + { name = "nemo-relay", specifier = "~=0.5.0" }, ] [[package]] @@ -2239,14 +2239,14 @@ requires-dist = [ [[package]] name = "nemo-relay" -version = "0.4.0" +version = "0.5.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/f0/1626672dd86005570afbcd4e368f5f271bcd2104ceaee883e839b11bfc81/nemo_relay-0.4.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5f14fe5c8e5fbcc26827cc88e5b867f4ad37d6f983100cbf31705234c39f9d3", size = 6674344, upload-time = "2026-06-12T23:24:02.707Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/956142c558334ec8594d97da6a1a717e70ba43685659dbdf18965c87c3f5/nemo_relay-0.4.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a5a5f5dec1428085f6c41c3645f1773f9cfb154cfa47306c93d6514091a8006", size = 5989959, upload-time = "2026-06-12T23:24:04.496Z" }, - { url = "https://files.pythonhosted.org/packages/6c/56/e5172ec8486deb23dabf5c948be81b8172840efdfcda42b1da1a8d9ef969/nemo_relay-0.4.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f92883b81540076e4b6c5e754eb7726225336634204ebd22ff730ccbcfb2723", size = 6332992, upload-time = "2026-06-12T23:24:06.288Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ea/f44b16ecfebc1de47e2d832e63d9cf56ad24952f09d6182d9fa9fa276b05/nemo_relay-0.4.0-cp311-abi3-win_amd64.whl", hash = "sha256:a5d02d9d0a2d19bf341bf9fd1faae01713bc2021a5b144ef79f91a07ec4c0bfb", size = 6332134, upload-time = "2026-06-12T23:24:08.017Z" }, - { url = "https://files.pythonhosted.org/packages/3a/45/eeedc7b7b5d2dea6be24f6ccca529abf3f97568d8dccff35de6430187ebe/nemo_relay-0.4.0-cp311-abi3-win_arm64.whl", hash = "sha256:dc1effd4052d1d47a1cdc553415c878a73d1e01c81a7e9f9194b30a3c88d1947", size = 5964432, upload-time = "2026-06-12T23:24:09.789Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fc/6892859ffbfb60f8cc09a4181c8d407b574d49490e35cd563e7f0a61bf54/nemo_relay-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:203409e0f392fc4f40f52277347ed75ba9969b2af28ddb3b6ae53f789185a0ed", size = 7769721, upload-time = "2026-07-08T18:43:57.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/13/25c4fcf6fb979af07c9562238819183588a910c2ae09767603500d6948c2/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de61ad0644c5e8c300de814f6884c5de7ae51db14694f5057f938b4da54bfaaf", size = 7037083, upload-time = "2026-07-08T18:44:00.135Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/bd4c77e975d50f7d09f8bfcb74d7e704e05a8b2205f555bf557f84edc5fe/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb323569f104506f4f1a0e2104b22d74c9a0bbf3280ec4d58ad3d768de76fb15", size = 7430817, upload-time = "2026-07-08T18:44:02.165Z" }, + { url = "https://files.pythonhosted.org/packages/da/8b/5681f3430e6d25bf7419dbe576da14599e1cfadc083edb74e691a0c3a980/nemo_relay-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:da1bd1e1dec79b7bda3984dd8c59b32ba97abf96c37e85cd1f38d208a29f42b9", size = 7359048, upload-time = "2026-07-08T18:44:04.169Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f2/65c3ef0435e671c829063d4872897f9054f369f0aeacab12c5aefe486705/nemo_relay-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:87462585f17b40ce82c54347acef5ace96ee11de3015f234e0b902f23b3793fb", size = 6934235, upload-time = "2026-07-08T18:44:06.078Z" }, ] [[package]] From 30bf20ca9c585c453a4a38230750b9411e4f2d68 Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:24:35 -0700 Subject: [PATCH 2/5] feat(tools): add blocked tools policy Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- README.md | 13 ++ adapters/claude/README.md | 4 +- .../nemo_fabric_adapters/claude/adapter.py | 38 +++--- .../src/nemo_fabric_adapters/common/hermes.py | 12 +- .../src/nemo_fabric_adapters/common/utils.py | 19 +++ adapters/deepagents/README.md | 13 +- .../deepagents/adapter.py | 62 +++++++-- adapters/hermes-cli/README.md | 2 + adapters/hermes-sdk/README.md | 2 + .../hermes_sdk/adapter.py | 3 +- crates/fabric-core/src/config.rs | 122 ++++++++++++++++-- .../api/python-library-reference/index.md | 1 + .../nemo_fabric.models.md | 71 ++++++++++ .../nemo_fabric.types.md | 1 - examples/code_review_agent/config.py | 16 +-- python/src/nemo_fabric/__init__.py | 2 + python/src/nemo_fabric/models.py | 22 +++- python/src/nemo_fabric/types.py | 65 +++++++++- schemas/adapter-invocation.schema.json | 41 ++++++ schemas/agent.schema.json | 22 ++++ schemas/effective-config.schema.json | 22 ++++ schemas/profile.schema.json | 24 ++++ schemas/run-plan.schema.json | 41 ++++++ tests/_utils/utils.py | 4 +- .../adapters/test_adapaters_common_hermes.py | 22 ++-- tests/adapters/test_claude_adapter.py | 31 +++-- tests/adapters/test_codex_cli.py | 1 + tests/adapters/test_deepagents.py | 37 ++++++ tests/e2e/test_claude.py | 23 +--- tests/python/test_sdk_contract.py | 30 +++++ 30 files changed, 660 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index db077d2db..ba62ae712 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,19 @@ authentication, and execution details. - **Profiles:** named variations of the base config. Use profiles to vary the harness, model, MCP, tools, skills, telemetry, or environment context without editing `agent.yaml`. +- **Tools policy:** use top-level `tools.blocked` for harness-neutral blocked + tool policy. Names are interpreted by the selected adapter: + + ```yaml + tools: + blocked: + - browser + - shell + ``` + + Hermes maps these names to disabled toolsets, Claude maps them to + `disallowed_tools`, Deep Agents enforces them with middleware, and adapters + without a native deny mechanism route the policy as unsupported. - **Adapters:** harness-specific integrations selected by `harness.adapter_id`. The Hermes SDK and CLI adapters live under `adapters/hermes-sdk/` and `adapters/hermes-cli/`; the Codex CLI adapter lives under diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 18c65a4c1..cff1e46a0 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -37,7 +37,9 @@ Configure portable capabilities through the normalized `FabricConfig` fields: `provider="anthropic"`; normalized hosted/custom provider resolution is tracked in [FABRIC-64](https://linear.app/nvidia/issue/FABRIC-64/add-normalized-model-provider-resolution-and-harness-compatibility). - `environment.workspace` sets the Claude working directory. -- `tools` sets the base Claude tool list. +- `tools.blocked` maps to Claude `disallowed_tools` using Claude-native tool + names. Adapter-specific `harness.settings.disallowed_tools` remains supported; + when both are present, the effective deny-list is the union. - `mcp` configures stdio, HTTP, streamable HTTP, or SSE servers. For stdio, Fabric parses `url` as a command plus arguments. - `skills.paths` names skill directories that contain `SKILL.md`. The adapter diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 249b5f830..2eb4f9737 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -11,26 +11,23 @@ import os import shlex import shutil -from dataclasses import asdict, is_dataclass +from dataclasses import asdict +from dataclasses import is_dataclass from hashlib import sha256 from pathlib import Path from typing import Any -from claude_agent_sdk import ( - CLIConnectionError, - CLIJSONDecodeError, - CLINotFoundError, - ClaudeAgentOptions, - ClaudeSDKError, - Message, - ProcessError, - ResultMessage, - query, -) -from claude_agent_sdk._errors import MessageParseError - import nemo_fabric_adapters.common.utils as common_utils - +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKError +from claude_agent_sdk import CLIConnectionError +from claude_agent_sdk import CLIJSONDecodeError +from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import Message +from claude_agent_sdk import ProcessError +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import query +from claude_agent_sdk._errors import MessageParseError PERMISSION_MODES = {"default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"} SETTING_SOURCES = {"user", "project", "local"} @@ -233,6 +230,8 @@ def _normalized_tools( if include_skills and "Skill" not in normalized: normalized.append("Skill") return normalized + if isinstance(tools, dict) and "blocked" in tools: + return None if isinstance(tools, dict) and tools != {"type": "preset", "preset": "claude_code"}: raise AdapterConfigError( "claude_invalid_configuration", @@ -241,6 +240,13 @@ def _normalized_tools( return tools +def _disallowed_tools(payload: dict[str, Any], settings: dict[str, Any]) -> list[str]: + return common_utils.merge_unique( + common_utils.blocked_tools(payload), + _string_list(settings.get("disallowed_tools"), name="disallowed_tools"), + ) + + def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: native = _mapping( common_utils.capability_plan(payload), name="capability_plan" @@ -338,7 +344,7 @@ def build_options(payload: dict[str, Any], *, resume: str | None) -> ClaudeAgent system_prompt=system_prompt, tools=_normalized_tools(payload, include_skills=bool(plugins)), allowed_tools=_string_list(settings.get("allowed_tools"), name="allowed_tools"), - disallowed_tools=_string_list(settings.get("disallowed_tools"), name="disallowed_tools"), + disallowed_tools=_disallowed_tools(payload, settings), permission_mode=permission_mode, max_turns=max_turns, max_budget_usd=max_budget, diff --git a/adapters/common/src/nemo_fabric_adapters/common/hermes.py b/adapters/common/src/nemo_fabric_adapters/common/hermes.py index fb792329d..dc4c86750 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/hermes.py +++ b/adapters/common/src/nemo_fabric_adapters/common/hermes.py @@ -45,6 +45,14 @@ def validate_hermes_telemetry_provider(payload: dict[str, Any]) -> None: raise ValueError("only relay telemetry is supported for Hermes") +def disabled_toolsets(payload: dict[str, Any]) -> list[str]: + settings = common_utils.settings_payload(payload) + return common_utils.merge_unique( + common_utils.blocked_tools(payload), + settings.get("disabled_toolsets"), + ) + + def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) -> dict[str, Any]: settings = common_utils.settings_payload(payload) model_config = selected_model_config(payload) @@ -55,6 +63,7 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) provider = settings.get("provider") or model_config.get("provider") base_url = get_base_url(settings, model_config) + blocked_toolsets = disabled_toolsets(payload) config: dict[str, Any] = { "model": without_none( { @@ -66,7 +75,7 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) "agent": without_none( { "max_turns": settings.get("max_iterations"), - "disabled_toolsets": settings.get("disabled_toolsets"), + "disabled_toolsets": blocked_toolsets or None, } ), "terminal": without_none( @@ -144,6 +153,7 @@ def summarize_hermes_config(config: dict[str, Any]) -> dict[str, Any]: "mcp_servers": sorted((config.get("mcp_servers") or {}).keys()), "plugins": (config.get("plugins") or {}).get("enabled", []), "platform_toolsets": config.get("platform_toolsets", {}), + "disabled_toolsets": (config.get("agent") or {}).get("disabled_toolsets", []), } diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index fc590d370..d053f00a4 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -133,6 +133,16 @@ def capability_plan(payload: dict[str, Any]) -> dict[str, Any]: return payload.get("capability_plan") or payload.get("capabilities") or {} +def tools_config(payload: dict[str, Any]) -> dict[str, Any]: + tools = fabric_config(payload).get("tools") or {} + return tools if isinstance(tools, dict) else {} + + +def blocked_tools(payload: dict[str, Any]) -> list[str]: + blocked = tools_config(payload).get("blocked") + return normalize_list(blocked) + + def normalize_list(value: Any) -> list[str]: if value is None: return [] @@ -143,6 +153,15 @@ def normalize_list(value: Any) -> list[str]: return [str(item) for item in value if str(item)] +def merge_unique(*values: Any) -> list[str]: + merged: list[str] = [] + for value in values: + for item in normalize_list(value): + if item not in merged: + merged.append(item) + return merged + + def dump_yaml(value: dict[str, Any]) -> str: try: import yaml diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index c05ad54ea..dff94e718 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -52,12 +52,9 @@ Fabric maps the following into the harness: - Configured MCP servers are loaded as Deep Agents tools via `langchain-mcp-adapters`. A misconfigured server (non-mapping, empty target, unsupported transport) is a normalized configuration failure, not a silent drop. -- `tools` (Fabric's `config.tools` allow-list) is enforced by a gating middleware - across the full tool surface — Deep Agents built-ins (including `task`), MCP - tools, and **delegated subagents** alike; tool calls whose name is not on the - list are blocked, so tools routed through the `task` tool cannot run ungated. A - non-list `tools` value is a normalized configuration failure rather than a - silently disabled allow-list. +- `tools.blocked` is enforced by middleware across the full tool surface — Deep + Agents built-ins (including `task`), MCP tools, and **delegated subagents** + alike. Use Deep Agents/native tool names in the blocked list. - `harness.settings.deepagents` forwards a small set of **documented, JSON-serializable** `create_deep_agent` options (currently `subagents` and `interrupt_on`). It is not a general Python-object escape hatch: the SDK config @@ -72,7 +69,7 @@ Fabric maps the following into the harness: Deep Agents can delegate to subagents through its built-in `task` tool. Subagents **inherit** the parent run's model, tools, skills, workspace, telemetry, and -permissions — in particular, the parent `config.tools` allow-list applies to +permissions — in particular, the parent `tools.blocked` policy applies to delegated execution, so a subagent cannot broaden capabilities beyond the parent. Independently configured subagent tools, skills, models, MCP servers, middleware, or permissions are **not** exposed through the Fabric SDK yet; a `subagents` @@ -139,6 +136,6 @@ async with await client.start_runtime(config, base_dir=BASE_DIR) as runtime: ATOF/ATIF artifacts referenced in the `ArtifactManifest`. OTel/OpenInference export is available through the relay plugin config (see the `relay-otel` and `relay-openinference` profiles). -- **Native** (`telemetry.providers.native.config`): the provider config +- **Native** (`telemetry.providers.native.config`): the configured OpenTelemetry/OpenInference exporter is applied and spans export directly to the configured collector, without writing ATOF/ATIF relay artifacts. diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index bdc191977..06e8dc983 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -219,6 +219,8 @@ def _allowed_tool_names(payload: dict[str, Any]) -> set[str] | None: tools = common_utils.fabric_config(payload).get("tools") if tools is None: return None + if isinstance(tools, dict) and "blocked" in tools: + return None if not isinstance(tools, (list, str)): raise AdapterConfigError( "config.tools must be a list of tool names (a deny/allow-list), " @@ -227,38 +229,58 @@ def _allowed_tool_names(payload: dict[str, Any]) -> set[str] | None: return set(common_utils.normalize_list(tools)) -def allowed_tools_middleware(allowed: set[str]) -> Any: - """Middleware that blocks tool calls whose name is not in the allow-list. +def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: + return set(common_utils.blocked_tools(payload)) - Enforces Fabric's ``config.tools`` allow-list across the *full* tool surface - (Deep Agents built-ins such as ``write_file``/``execute``/``task`` and MCP - tools alike), since the built-ins are contributed by middleware rather than the - ``tools`` argument and cannot be pre-filtered out of the ``tools`` list. - """ +def _tool_gate_middleware(is_blocked: Any, message: Any) -> Any: from langchain.agents.middleware import AgentMiddleware from langchain_core.messages import ToolMessage def _blocked(request: Any) -> Any: name = request.tool_call.get("name") return ToolMessage( - content=f"Tool '{name}' is not permitted by the configured tools allow-list.", + content=message(name), tool_call_id=request.tool_call.get("id", ""), status="error", ) - class AllowedToolsMiddleware(AgentMiddleware): # type: ignore[misc] + class ToolGateMiddleware(AgentMiddleware): # type: ignore[misc] async def awrap_tool_call(self, request: Any, handler: Any) -> Any: - if request.tool_call.get("name") not in allowed: + if is_blocked(request.tool_call.get("name")): return _blocked(request) return await handler(request) def wrap_tool_call(self, request: Any, handler: Any) -> Any: - if request.tool_call.get("name") not in allowed: + if is_blocked(request.tool_call.get("name")): return _blocked(request) return handler(request) - return AllowedToolsMiddleware() + return ToolGateMiddleware() + + +def allowed_tools_middleware(allowed: set[str]) -> Any: + """Middleware that blocks tool calls whose name is not in the allow-list. + + Enforces Fabric's ``config.tools`` allow-list across the *full* tool surface + (Deep Agents built-ins such as ``write_file``/``execute``/``task`` and MCP + tools alike), since the built-ins are contributed by middleware rather than the + ``tools`` argument and cannot be pre-filtered out of the ``tools`` list. + """ + + return _tool_gate_middleware( + lambda name: name not in allowed, + lambda name: f"Tool '{name}' is not permitted by the configured tools allow-list.", + ) + + +def blocked_tools_middleware(blocked: set[str]) -> Any: + """Middleware that blocks explicitly denied tool calls across the full tool surface.""" + + return _tool_gate_middleware( + lambda name: name in blocked, + lambda name: f"Tool '{name}' is blocked by the configured tools policy.", + ) def resolve_skills(payload: dict[str, Any]) -> list[str] | None: @@ -398,6 +420,7 @@ async def build_agent_kwargs( if extra is not None: kwargs.update(_validated_passthrough(extra)) allowed = _allowed_tool_names(payload) + blocked = _blocked_tool_names(payload) if allowed is not None: # Gate the main agent AND every configured subagent, so the allow-list # covers the full tool surface. The built-in ``task`` tool (which spawns @@ -409,6 +432,13 @@ async def build_agent_kwargs( subagents = kwargs.get("subagents") if isinstance(subagents, list): kwargs["subagents"] = [_gate_subagent(sub, allowed) for sub in subagents] + if blocked: + middleware = list(kwargs.get("middleware") or []) + middleware.append(blocked_tools_middleware(blocked)) + kwargs["middleware"] = middleware + subagents = kwargs.get("subagents") + if isinstance(subagents, list): + kwargs["subagents"] = [_block_subagent(sub, blocked) for sub in subagents] return {key: value for key, value in kwargs.items() if value is not None} @@ -449,6 +479,14 @@ def _gate_subagent(subagent: Any, allowed: set[str]) -> Any: return gated +def _block_subagent(subagent: Any, blocked: set[str]) -> Any: + if not isinstance(subagent, dict): + return subagent + gated = dict(subagent) + gated["middleware"] = [*(gated.get("middleware") or []), blocked_tools_middleware(blocked)] + return gated + + async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: settings = common_utils.settings_payload(payload) request = payload.get("request") or {} diff --git a/adapters/hermes-cli/README.md b/adapters/hermes-cli/README.md index 43d270ee2..8d2464c2e 100644 --- a/adapters/hermes-cli/README.md +++ b/adapters/hermes-cli/README.md @@ -24,6 +24,8 @@ before calling the CLI. It maps: - workspace and terminal settings; - Fabric skills as Hermes external skill directories; - Fabric MCP servers as Hermes MCP server config; +- `tools.blocked` as Hermes disabled toolsets in the generated config, unioned + with `harness.settings.disabled_toolsets`; - selected CLI flags and environment variables from harness settings. `hermes_home` configures a base directory. The adapter creates a child under diff --git a/adapters/hermes-sdk/README.md b/adapters/hermes-sdk/README.md index 9854d756c..0696419ab 100644 --- a/adapters/hermes-sdk/README.md +++ b/adapters/hermes-sdk/README.md @@ -22,6 +22,8 @@ configuration for: - workspace and terminal settings; - Fabric skills as Hermes external skill directories; - Fabric MCP servers as Hermes MCP server config; +- `tools.blocked` as Hermes disabled toolsets, unioned with + `harness.settings.disabled_toolsets`; - optional NeMo Relay telemetry plugin configuration. `hermes_home` configures a base directory. The adapter creates a child under diff --git a/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py b/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py index 81ceb5aa5..e3e43f0e6 100755 --- a/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py +++ b/adapters/hermes-sdk/src/nemo_fabric_adapters/hermes_sdk/adapter.py @@ -178,6 +178,7 @@ def _invoke_hermes( discover_plugins(force=True) loaded_hermes_config = load_config() enabled_toolsets = resolve_hermes_toolsets(settings, loaded_hermes_config) + disabled_toolsets = hermes_common.disabled_toolsets(payload) session_id = common_utils.runtime_id(payload) session_db = SessionDB() conversation_history = load_runtime_history(session_db, session_id) @@ -191,7 +192,7 @@ def _invoke_hermes( model=settings.get("model_name") or model_config.get("model", ""), max_iterations=int(settings.get("max_iterations", 1)), enabled_toolsets=enabled_toolsets, - disabled_toolsets=settings.get("disabled_toolsets"), + disabled_toolsets=disabled_toolsets or None, quiet_mode=True, skip_context_files=True, skip_memory=True, diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 6b41f825e..02e6287ef 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -52,7 +52,7 @@ pub struct FabricConfig { pub environment: Option, /// Tool capability configuration. #[serde(default, skip_serializing_if = "Option::is_none")] - pub tools: Option, + pub tools: Option, /// Skill capability configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub skills: Option, @@ -90,6 +90,17 @@ impl ProfileRegistryConfig { } } +/// Harness-neutral tool capability configuration. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolsConfig { + /// Adapter-native tool names or toolset names to block. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked: Vec, + /// Additive tool configuration fields. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + /// Human-readable metadata. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct MetadataConfig { @@ -404,7 +415,7 @@ struct ProfileConfigSchema { /// Partial environment overlay. environment: Option>, /// Tool capability overlay. - tools: Option, + tools: Option, /// Partial skill overlay. skills: Option>, /// Partial MCP overlay. @@ -1579,13 +1590,19 @@ fn resolve_capability_plan( .collect() }) .unwrap_or_default(); - let tools_are_native = config.tools.is_some() && accepts("tools"); + let blocked_tools = config + .tools + .as_ref() + .map(|tools| tools.blocked.clone()) + .unwrap_or_default(); + let tools_configured = !blocked_tools.is_empty(); + let tools_are_native = tools_configured && accepts("tools"); let mut native = CapabilityTargetPlan::default(); let managed = CapabilityTargetPlan::default(); let mut unsupported = CapabilityTargetPlan::default(); let mut routes = Vec::new(); - if config.tools.is_some() { + if tools_configured { if tools_are_native { native.tools_configured = true; routes.push(CapabilityRoute { @@ -1656,7 +1673,10 @@ fn resolve_capability_plan( } CapabilityPlan { - tools_configured: config.tools.is_some(), + tools: ToolsPlan { + blocked: blocked_tools, + }, + tools_configured, skill_paths, mcp_servers, native, @@ -1860,6 +1880,9 @@ pub struct EnvironmentPlan { /// Resolved capability configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CapabilityPlan { + /// Normalized tool policy. + #[serde(default)] + pub tools: ToolsPlan, /// Whether tool configuration was provided. #[serde(default)] pub tools_configured: bool, @@ -1883,6 +1906,14 @@ pub struct CapabilityPlan { pub routes: Vec, } +/// Normalized tool policy for a run. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ToolsPlan { + /// Adapter-native tool names or toolset names to block. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked: Vec, +} + /// Capabilities routed to one target. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct CapabilityTargetPlan { @@ -1996,8 +2027,8 @@ runtime: input_schema: chat output_schema: message tools: - enabled: [base] - cleared: + blocked: [base] + future: value: true future_top_level: base: true @@ -2016,8 +2047,8 @@ harness: runtime: input_schema: prompt tools: - enabled: [profile] - cleared: null + blocked: [profile] + future: null future_top_level: profile: true nested: @@ -2038,8 +2069,8 @@ future_top_level: assert_eq!(value["runtime"]["input_schema"], "prompt"); assert_eq!(value["harness"]["settings"]["workspace"], "./workspace"); assert_eq!(value["harness"]["settings"]["timeout_seconds"], 30); - assert_eq!(value["tools"]["enabled"], serde_json::json!(["profile"])); - assert!(value["tools"]["cleared"].is_null()); + assert_eq!(value["tools"]["blocked"], serde_json::json!(["profile"])); + assert!(value["tools"]["future"].is_null()); assert_eq!(value["future_top_level"]["base"], true); assert_eq!(value["future_top_level"]["profile"], true); assert_eq!(value["future_top_level"]["nested"]["first"], 1); @@ -2492,7 +2523,8 @@ runtime: input_schema: text output_schema: text tools: - - name: shell + blocked: + - shell skills: paths: - ./skills/review @@ -2521,6 +2553,7 @@ mcp: assert!(!plan.capability_plan.managed.tools_configured); assert!(plan.capability_plan.managed.skill_paths.is_empty()); assert!(plan.capability_plan.managed.mcp_servers.is_empty()); + assert_eq!(plan.capability_plan.tools.blocked, vec!["shell"]); assert!(plan.capability_plan.unsupported.tools_configured); assert_eq!(plan.capability_plan.unsupported.skill_paths.len(), 1); assert!( @@ -2541,6 +2574,71 @@ mcp: let _ = std::fs::remove_dir_all(root); } + #[test] + fn blocked_tools_route_to_native_only_when_non_empty_and_supported() { + let root = std::env::temp_dir().join(format!( + "fabric-blocked-tools-capability-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("adapters/tools")).expect("create adapters"); + std::fs::write( + root.join("adapters/tools/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.tools", + "harness": "tools", + "adapter_kind": "process", + "config": {"accepts": ["tools"]} +}"#, + ) + .expect("write adapter descriptor"); + std::fs::write( + root.join("agent.yaml"), + r#"schema_version: fabric.agent/v1alpha1 +metadata: + name: blocked-tools-agent +harness: + adapter_id: acme.fabric.tools +runtime: +tools: + blocked: + - browser +"#, + ) + .expect("write agent config"); + + let plan = resolve_run_plan(&root, None).expect("run plan"); + + assert_eq!(plan.capability_plan.tools.blocked, vec!["browser"]); + assert!(plan.capability_plan.tools_configured); + assert!(plan.capability_plan.native.tools_configured); + assert!(!plan.capability_plan.unsupported.tools_configured); + + std::fs::write( + root.join("agent.yaml"), + r#"schema_version: fabric.agent/v1alpha1 +metadata: + name: blocked-tools-agent +harness: + adapter_id: acme.fabric.tools +runtime: +tools: + blocked: [] +"#, + ) + .expect("write empty tools config"); + + let plan = resolve_run_plan(&root, None).expect("run plan"); + + assert!(plan.capability_plan.tools.blocked.is_empty()); + assert!(!plan.capability_plan.tools_configured); + assert!(!plan.capability_plan.native.tools_configured); + assert!(plan.capability_plan.routes.is_empty()); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn later_profiles_override_earlier_profiles() { let profiles = vec!["env_opensandbox".to_string(), "env_local".to_string()]; diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index c2b93b66c..a9194c666 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -46,6 +46,7 @@ SPDX-License-Identifier: Apache-2.0 */} - [`models.SkillConfig`](./nemo_fabric.models.md#class-skillconfig): Skill capability configuration. - [`models.TelemetryConfig`](./nemo_fabric.models.md#class-telemetryconfig): Telemetry configuration. - [`models.TelemetryProviderConfig`](./nemo_fabric.models.md#class-telemetryproviderconfig): Provider-specific telemetry configuration. +- [`models.ToolsConfig`](./nemo_fabric.models.md#class-toolsconfig): Harness-neutral tool capability configuration. - [`types.AdapterInfo`](./nemo_fabric.types.md#class-adapterinfo): Resolved adapter identity attached to a run plan. - [`types.ArtifactManifest`](./nemo_fabric.types.md#class-artifactmanifest): Normalized collection of artifacts produced by a run. - [`types.ArtifactRef`](./nemo_fabric.types.md#class-artifactref): Reference to one artifact produced by a run. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 39ca5d2ab..1c8b4dd08 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -1390,6 +1390,66 @@ Returns the set of fields that have been explicitly set on this model instance. +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + + +### classmethod `from_mapping` + +```python +from_mapping(value: 'Mapping[str, Any]') → Self +``` + +Validate a mapping using this Pydantic model. + +--- + + +### method `to_mapping` + +```python +to_mapping() → dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- + + +## class `ToolsConfig` +Harness-neutral tool capability configuration. + + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + **Returns:** A set of strings representing the fields that have been set, i.e. that were not filled from defaults. @@ -1486,6 +1546,17 @@ Add a skill path and return this config. --- +### method `block_tools` + +```python +block_tools(*tools: 'str') → Self +``` + +Block adapter-native tool names or toolsets and return this config. + +--- + + ### method `enable_relay` ```python diff --git a/docs/reference/api/python-library-reference/nemo_fabric.types.md b/docs/reference/api/python-library-reference/nemo_fabric.types.md index 3b05fbfc4..6d70a2676 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.types.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md @@ -856,7 +856,6 @@ Return a detached, JSON-compatible mapping for serialization. ## class `RunOutput` - Normalized adapter output. ``response`` is a known adapter response field whose value follows the core Fabric JSON contract. Other keys are adapter-specific extensions. diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index dfddf783c..960d73dc7 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -6,6 +6,7 @@ from __future__ import annotations from pathlib import Path +from typing import cast from nemo_fabric import EnvironmentConfig from nemo_fabric import FabricConfig @@ -14,6 +15,7 @@ from nemo_fabric import ModelConfig from nemo_fabric import RelayAtifConfig from nemo_fabric import RelayAtofConfig +from nemo_fabric import RelayConfig from nemo_fabric import RelayObservabilityConfig from nemo_fabric import RelayOtlpConfig from nemo_fabric import RuntimeConfig @@ -268,11 +270,9 @@ def with_relay_openinference(base: FabricConfig) -> FabricConfig: config = with_relay(base) assert config.telemetry is not None assert config.relay is not None - relay = config.relay - assert not isinstance(relay, dict) + relay = cast("RelayConfig", config.relay) assert relay.observability is not None - observability = relay.observability - assert not isinstance(observability, dict) + observability = cast("RelayObservabilityConfig", relay.observability) relay.output_dir = "./artifacts/relay-openinference" observability.openinference = RelayOtlpConfig( @@ -280,10 +280,10 @@ def with_relay_openinference(base: FabricConfig) -> FabricConfig: transport="http_binary", endpoint="http://localhost:6006/v1/traces", ) - if isinstance(observability.atif, RelayAtifConfig): - observability.atif.output_directory = "./artifacts/relay-openinference" - if isinstance(observability.atof, RelayAtofConfig): - observability.atof.output_directory = "./artifacts/relay-openinference" + atif = cast("RelayAtifConfig", observability.atif) + atof = cast("RelayAtofConfig", observability.atof) + atif.output_directory = "./artifacts/relay-openinference" + atof.output_directory = "./artifacts/relay-openinference" return config diff --git a/python/src/nemo_fabric/__init__.py b/python/src/nemo_fabric/__init__.py index 356cf6fb0..96cceeaa0 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -35,6 +35,7 @@ from nemo_fabric.models import SkillConfig from nemo_fabric.models import TelemetryConfig from nemo_fabric.models import TelemetryProviderConfig +from nemo_fabric.models import ToolsConfig from nemo_fabric.runtime import Runtime from nemo_fabric.runtime import RuntimeStatus from nemo_fabric.types import AdapterInfo @@ -101,4 +102,5 @@ "TelemetryConfig", "TelemetryProviderConfig", "TelemetryRef", + "ToolsConfig", ] diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index 098851634..c48711ade 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -394,6 +394,12 @@ class ProfileRegistryConfig(FabricBaseModel): directories: list[str | Path] = Field(default_factory=list) +class ToolsConfig(FabricBaseModel): + """Harness-neutral tool capability configuration.""" + + blocked: list[str] = Field(default_factory=list) + + class FabricConfig(FabricBaseModel): """SDK-facing typed Fabric agent configuration.""" @@ -408,7 +414,7 @@ class FabricConfig(FabricBaseModel): telemetry: TelemetryConfig | None = None relay: RelayConfig | dict[str, Any] | None = None profiles: ProfileRegistryConfig | dict[str, Any] | None = None - tools: Any = None + tools: ToolsConfig | dict[str, Any] | None = None @classmethod def from_mapping(cls, value: Mapping[str, Any]) -> Self: @@ -472,6 +478,18 @@ def remove_skill_path(self, path: str | Path) -> Self: self.skills = None return self + def block_tools(self, *tools: str) -> Self: + """Block adapter-native tool names or toolsets and return this config.""" + + if self.tools is None or isinstance(self.tools, dict): + self.tools = ToolsConfig.model_validate(self.tools or {}) + existing = list(self.tools.blocked) + for tool in tools: + if tool not in existing: + existing.append(tool) + self.tools.blocked = existing + return self + def enable_relay( self, *, @@ -519,7 +537,7 @@ class FabricProfileConfig(FabricBaseModel): skills: SkillConfig | dict[str, Any] | None = None telemetry: TelemetryConfig | dict[str, Any] | None = None relay: RelayConfig | dict[str, Any] | None = None - tools: Any = None + tools: ToolsConfig | dict[str, Any] | None = None class RunRequest(FabricBaseModel): diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index f75018d61..40478c9d5 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -369,6 +369,50 @@ def remove_path(self, path: str | Path) -> "_SkillConfig": return self +class _ToolsConfig(_ConfigMapping): + """Harness-neutral tool capability configuration.""" + + _fields = frozenset({"blocked"}) + _omit_if_empty = frozenset({"blocked"}) + + def __init__( + self, + *, + blocked: Sequence[str] | None = None, + extra_fields: Mapping[str, Any] | None = None, + ) -> None: + if blocked is not None and ( + isinstance(blocked, (str, bytes)) or not isinstance(blocked, Sequence) + ): + raise FabricConfigError("tools blocked must be an ordered sequence of strings") + values: dict[str, Any] = {"blocked": [_required_text(tool, "blocked tool") for tool in (blocked or [])]} + super().__init__(values, extra_fields=extra_fields) + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> _ToolsConfig: + """Validate a tools mapping and preserve extension fields.""" + + data = _mapping(value, "tools") + blocked = data.get("blocked", []) + if isinstance(blocked, (str, bytes)) or not isinstance(blocked, Sequence): + raise FabricConfigError("tools blocked must be an ordered sequence of strings") + return cls( + blocked=blocked, + extra_fields={key: item for key, item in data.items() if key not in cls._fields}, + ) + + def block(self, *tools: str) -> _ToolsConfig: + """Block adapter-native tool names or toolsets.""" + + blocked = list(self.get("blocked", [])) + for tool in tools: + value = _required_text(tool, "blocked tool") + if value not in blocked: + blocked.append(value) + self["blocked"] = blocked + return self + + class _McpConfig(_ConfigMapping): """MCP capability configuration with authoring helpers.""" @@ -563,7 +607,7 @@ def __init__( telemetry: Mapping[str, Any] | None = None, relay: Mapping[str, Any] | None = None, profiles: Mapping[str, Any] | None = None, - tools: Any = None, + tools: Mapping[str, Any] | None = None, extra_fields: Mapping[str, Any] | None = None, ) -> None: metadata_value = _coerce(_MetadataConfig, metadata, "metadata") @@ -578,6 +622,7 @@ def __init__( skills_value = None if skills is None else _coerce(_SkillConfig, skills, "skills") telemetry_value = None if telemetry is None else _coerce(_TelemetryConfig, telemetry, "telemetry") relay_value = None if relay is None else _mapping(relay, "relay") + tools_value = None if tools is None else _coerce(_ToolsConfig, tools, "tools") values: dict[str, Any] = { "schema_version": _required_text(schema_version, "schema_version"), "metadata": metadata_value, @@ -592,7 +637,7 @@ def __init__( ("telemetry", telemetry_value), ("relay", relay_value), ("profiles", profiles), - ("tools", tools), + ("tools", tools_value), ): if item is not None: values[key] = item @@ -643,6 +688,16 @@ def skills(self) -> _SkillConfig: def skills(self, value: _SkillConfig | Mapping[str, Any]) -> None: self["skills"] = _coerce(_SkillConfig, value, "skills") + @property + def tools(self) -> _ToolsConfig: + """Mutable tool capability config, created on first access.""" + + return self._ensure_section("tools", _ToolsConfig) + + @tools.setter + def tools(self, value: _ToolsConfig | Mapping[str, Any]) -> None: + self["tools"] = _coerce(_ToolsConfig, value, "tools") + @property def telemetry(self) -> _TelemetryConfig: """Mutable telemetry config, created on first access.""" @@ -689,6 +744,12 @@ def add_skill_path(self, path: str | Path) -> "_ResolvedFabricConfig": self.skills.add_path(path) return self + def block_tools(self, *tools: str) -> _ResolvedFabricConfig: + """Block adapter-native tool names or toolsets and return this config.""" + + self.tools.block(*tools) + return self + def enable_relay( self, *, diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 488e511f5..d1624c1ca 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -108,6 +108,11 @@ }, "type": "array" }, + "tools": { + "$ref": "#/$defs/ToolsPlan", + "default": {}, + "description": "Normalized tool policy." + }, "tools_configured": { "default": false, "description": "Whether tool configuration was provided.", @@ -459,6 +464,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1486,6 +1499,33 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolsPlan": { + "description": "Normalized tool policy for a run.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1500,6 +1540,7 @@ "native": { "tools_configured": false }, + "tools": {}, "tools_configured": false, "unsupported": { "tools_configured": false diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index a15c83d31..f664b81ed 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -912,6 +912,20 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1001,6 +1015,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index 60fb1062d..6fc533a5b 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -170,6 +170,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1010,6 +1018,20 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/schemas/profile.schema.json b/schemas/profile.schema.json index 085fd50df..63ebc6e0d 100644 --- a/schemas/profile.schema.json +++ b/schemas/profile.schema.json @@ -1,4 +1,20 @@ { + "$defs": { + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": true, "properties": { @@ -88,6 +104,14 @@ ] }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability overlay." } }, diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 1f14a4793..3767d5140 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -237,6 +237,11 @@ }, "type": "array" }, + "tools": { + "$ref": "#/$defs/ToolsPlan", + "default": {}, + "description": "Normalized tool policy." + }, "tools_configured": { "default": false, "description": "Whether tool configuration was provided.", @@ -588,6 +593,14 @@ "description": "Telemetry configuration." }, "tools": { + "anyOf": [ + { + "$ref": "#/$defs/ToolsConfig" + }, + { + "type": "null" + } + ], "description": "Tool capability configuration." } }, @@ -1573,6 +1586,33 @@ } }, "type": "object" + }, + "ToolsConfig": { + "additionalProperties": true, + "description": "Harness-neutral tool capability configuration.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolsPlan": { + "description": "Normalized tool policy for a run.", + "properties": { + "blocked": { + "description": "Adapter-native tool names or toolset names to block.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1610,6 +1650,7 @@ "native": { "tools_configured": false }, + "tools": {}, "tools_configured": false, "unsupported": { "tools_configured": false diff --git a/tests/_utils/utils.py b/tests/_utils/utils.py index b5570b937..35c102502 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -44,9 +44,7 @@ def assert_relay_disabled_native_observability(result: dict): event_kinds = {event["kind"] for event in result["events"]} assert {"runtime_start", "invocation_start", "invocation_end"} <= event_kinds - telemetry = result.get("telemetry") - if telemetry is not None: - assert telemetry["relay_enabled"] is False + assert "telemetry" not in result or result["telemetry"]["relay_enabled"] is False def assert_process_adapter_native_observability(result: dict): diff --git a/tests/adapters/test_adapaters_common_hermes.py b/tests/adapters/test_adapaters_common_hermes.py index 51066860a..1d2bb37da 100644 --- a/tests/adapters/test_adapaters_common_hermes.py +++ b/tests/adapters/test_adapaters_common_hermes.py @@ -102,15 +102,15 @@ def test_validate_hermes_telemetry_provider_accepts_relay( hermes_common.validate_hermes_telemetry_provider(payload) -def test_validate_hermes_telemetry_provider_rejects_native(): - payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} - - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): - hermes_common.validate_hermes_telemetry_provider(payload) - - -def test_validate_hermes_telemetry_provider_rejects_mixed_native_and_relay(): - payload = {"telemetry_plan": {"providers": ["relay", "native"], "relay_enabled": True}} +@pytest.mark.parametrize( + ("providers", "relay_enabled"), + [(["native"], False), (["relay", "native"], True)], +) +def test_validate_hermes_telemetry_provider_rejects_native( + providers: list[str], + relay_enabled: bool, +): + payload = {"telemetry_plan": {"providers": providers, "relay_enabled": relay_enabled}} with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): hermes_common.validate_hermes_telemetry_provider(payload) @@ -143,6 +143,7 @@ def test_build_hermes_config_maps_fabric_config_to_hermes_config(): "plugins_enabled": ["custom/plugin"], } }, + "tools": {"blocked": ["shell", "browser"]}, "models": { "review": { "provider": "nvidia", @@ -164,7 +165,7 @@ def test_build_hermes_config_maps_fabric_config_to_hermes_config(): }, "agent": { "max_turns": 4, - "disabled_toolsets": ["browser"], + "disabled_toolsets": ["shell", "browser"], }, "terminal": { "backend": "local", @@ -374,6 +375,7 @@ def test_summarize_hermes_config(): "mcp_servers": ["a", "z"], "plugins": ["observability/nemo_relay"], "platform_toolsets": {"cli": ["git"]}, + "disabled_toolsets": [], } diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 7cb68b038..72fec84dc 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -11,17 +11,15 @@ from unittest.mock import MagicMock import pytest -from claude_agent_sdk import ( - AssistantMessage, - CLIConnectionError, - CLIJSONDecodeError, - CLINotFoundError, - ClaudeSDKError, - ProcessError, - ResultMessage, - SystemMessage, - TextBlock, -) +from claude_agent_sdk import AssistantMessage +from claude_agent_sdk import ClaudeSDKError +from claude_agent_sdk import CLIConnectionError +from claude_agent_sdk import CLIJSONDecodeError +from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import ProcessError +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import SystemMessage +from claude_agent_sdk import TextBlock from claude_agent_sdk._errors import MessageParseError ROOT = Path(__file__).resolve().parents[2] @@ -129,8 +127,6 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_payload): - skill_path = Path(claude_payload["capability_plan"]["native"]["skill_paths"][0]) - options = adapter.build_options(claude_payload, resume="claude-session") assert options.resume == "claude-session" @@ -161,6 +157,15 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p } +def test_build_options_maps_blocked_tools_to_disallowed_tools(claude_payload): + claude_payload["effective_config"]["config"]["tools"] = {"blocked": ["Bash", "WebFetch"]} + + options = adapter.build_options(claude_payload, resume=None) + + assert options.tools is None + assert options.disallowed_tools == ["Bash", "WebFetch"] + + @pytest.mark.parametrize( ("name", "normalized_field"), [ diff --git a/tests/adapters/test_codex_cli.py b/tests/adapters/test_codex_cli.py index 286acec83..f97cc170c 100644 --- a/tests/adapters/test_codex_cli.py +++ b/tests/adapters/test_codex_cli.py @@ -577,6 +577,7 @@ def test_runtime_reuses_codex_thread_across_invocations(codex_payload, monkeypat child_env = mock_run.call_args_list[0].kwargs["env"] assert child_env["CODEX_HOME"] == str(tmp_path / "codex-home") assert child_env["CODEX_EXPLICIT"] == "forward-me" + assert "FABRIC_UNRELATED_SECRET" not in child_env assert "OPENAI_API_KEY" not in child_env assert mock_run.call_args_list[0].kwargs["timeout"] == 1800 diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 1aee5dd03..61a425bac 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -455,6 +455,27 @@ def request(name): assert denied.status == "error" +@pytest.mark.usefixtures("use_real_langgraph") +async def test_blocked_tools_middleware_blocks_configured_tools(): + pytest.importorskip("langchain.agents.middleware") + from langchain_core.messages import ToolMessage + + middleware = adapter.blocked_tools_middleware({"write_file"}) + + async def handler(_request): + return "executed" + + def request(name): + return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) + + blocked = await middleware.awrap_tool_call(request("write_file"), handler) + assert isinstance(blocked, ToolMessage) + assert blocked.status == "error" + + allowed = await middleware.awrap_tool_call(request("read_file"), handler) + assert allowed == "executed" + + def test_empty_tools_is_deny_all_not_none(): # An explicitly empty tools list is a deny-all allow-list, not "no allow-list". assert adapter._allowed_tool_names({"effective_config": {"config": {"tools": []}}}) == set() @@ -625,6 +646,22 @@ async def test_subagents_are_gated_by_allow_list(tmp_path, make_payload, fake_sd assert subagents[0]["middleware"], "subagent gating middleware not attached" +async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload, fake_sdks): + payload = make_payload(tmp_path) + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} + payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { + "subagents": [{"name": "researcher", "prompt": "research"}] + } + + await adapter.run_deepagents(payload) + + create_kwargs = fake_sdks["create_kwargs"] + assert create_kwargs["middleware"], "main agent blocked-tools middleware not attached" + subagents = create_kwargs["subagents"] + assert len(subagents) == 1 + assert subagents[0]["middleware"], "subagent blocked-tools middleware not attached" + + async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): # Documented JSON-serializable options reach create_deep_agent unchanged. payload = make_payload(tmp_path) diff --git a/tests/e2e/test_claude.py b/tests/e2e/test_claude.py index 0570eeb55..e9adeb977 100644 --- a/tests/e2e/test_claude.py +++ b/tests/e2e/test_claude.py @@ -11,16 +11,13 @@ from pathlib import Path import pytest -from nemo_fabric import ( - EnvironmentConfig, - Fabric, - FabricConfig, - HarnessConfig, - MetadataConfig, - ModelConfig, - RuntimeConfig, -) - +from nemo_fabric import EnvironmentConfig +from nemo_fabric import Fabric +from nemo_fabric import FabricConfig +from nemo_fabric import HarnessConfig +from nemo_fabric import MetadataConfig +from nemo_fabric import ModelConfig +from nemo_fabric import RuntimeConfig ROOT = Path(__file__).resolve().parents[2] MOCK_CLAUDE_CLI = ROOT / "tests" / "fixtures" / "claude" / "mock-claude-cli.py" @@ -65,7 +62,6 @@ def fabric_config(tmp_path, *, cli_path=None): skill_path = tmp_path / "skills" / "review" skill_path.mkdir(parents=True) (skill_path / "SKILL.md").write_text("# Review\n", encoding="utf-8") - config.tools = ["Read", "Glob", "Grep"] config.add_skill_path(skill_path) config.add_mcp_server( "docs", @@ -96,15 +92,10 @@ async def test_fabric_session_launches_fresh_processes_and_resumes(tmp_path): assert len(arguments) == 2 assert "--resume" not in arguments[0] assert arguments[1][arguments[1].index("--resume") + 1] == SESSION_ID - assert all( - args[args.index("--tools") + 1] == "Read,Glob,Grep,Skill" - for args in arguments - ) assert all("--mcp-config" in args for args in arguments) assert all("--plugin-dir" in args for args in arguments) plugin_paths = [args[args.index("--plugin-dir") + 1] for args in arguments] assert plugin_paths[0] == plugin_paths[1] - assert all("Skill" in args[args.index("--allowedTools") + 1] for args in arguments) assert not any(artifact.kind == "stderr" for artifact in second.artifacts.artifacts) diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index cc5e166b0..459c7be03 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -45,7 +45,9 @@ from nemo_fabric import RuntimeHandle from nemo_fabric import SkillConfig from nemo_fabric import TelemetryConfig +from nemo_fabric import ToolsConfig from nemo_fabric.types import _ResolvedFabricConfig +from nemo_fabric.types import _ToolsConfig from pydantic import ValidationError @@ -141,11 +143,14 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): project="fabric-tests", output_dir="./artifacts/relay", ) + config.block_tools("browser", "shell", "browser") assert isinstance(config.mcp, McpConfig) assert isinstance(config.skills, SkillConfig) assert isinstance(config.telemetry, TelemetryConfig) + assert isinstance(config.tools, ToolsConfig) + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} assert config.to_mapping()["skills"] == {"paths": ["./skills/review"]} assert config.to_mapping()["mcp"] == { "servers": { @@ -183,6 +188,31 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): TelemetryConfig(providers={"sideways": {}}) +def test_typed_tools_config_serializes_blocked_policy(): + config = FabricConfig( + metadata=MetadataConfig(name="demo"), + harness=HarnessConfig(adapter_id="test.fabric.shim"), + tools=ToolsConfig(blocked=["browser"]), + ) + + config.block_tools("shell", "browser") + + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} + + +def test_run_plan_config_block_tools_emits_canonical_shape(): + config = _ResolvedFabricConfig.from_mapping(_plan()["config"]) + + config.block_tools("browser", "shell", "browser") + + assert config.to_mapping()["tools"] == {"blocked": ["browser", "shell"]} + + +def test_run_plan_tools_config_rejects_scalar_blocked_value(): + with pytest.raises(FabricConfigError, match="tools blocked"): + _ToolsConfig(blocked="browser") # type: ignore[arg-type] + + def test_fabric_config_authors_first_class_relay_observability(): config = _fabric_config() From 7c7db88037b9a0b12291ce2c944293fdc84760ed Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:45:12 -0700 Subject: [PATCH 3/5] fix(tools): enforce blocked tool policies Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/claude/README.md | 3 +- .../nemo_fabric_adapters/claude/adapter.py | 24 +--- adapters/deepagents/README.md | 2 +- .../deepagents/adapter.py | 52 --------- crates/fabric-core/src/doctor.rs | 36 +++++- crates/fabric-core/src/error.rs | 8 ++ crates/fabric-core/src/runtime.rs | 38 ++++++- .../nemo_fabric.models.md | 1 + .../fabric-core/error/enum-fabricerror.mdx | 18 ++- scripts/generate_api_docs.sh | 2 + tests/adapters/test_claude_adapter.py | 24 +--- tests/adapters/test_codex_cli.py | 1 - tests/adapters/test_deepagents.py | 103 ++++-------------- 13 files changed, 124 insertions(+), 188 deletions(-) diff --git a/adapters/claude/README.md b/adapters/claude/README.md index cff1e46a0..a6ff12d62 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -81,6 +81,7 @@ from nemo_fabric import ( ModelConfig, RuntimeConfig, SkillConfig, + ToolsConfig, ) base_dir = Path("/workspace/review-agent") @@ -104,7 +105,7 @@ config = FabricConfig( }, runtime=RuntimeConfig(artifacts="./artifacts"), environment=EnvironmentConfig(provider="local", workspace="."), - tools=["Read", "Glob", "Grep"], + tools=ToolsConfig(blocked=["WebFetch"]), mcp=McpConfig( servers={ "repo": McpServerConfig( diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 73102d38e..e6182b739 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -208,28 +208,6 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: return result -def _normalized_tools(payload: dict[str, Any], *, include_skills: bool) -> list[str] | dict[str, Any] | None: - native = _mapping(common_utils.capability_plan(payload), name="capability_plan").get("native") or {} - if not _mapping(native, name="capability_plan.native").get("tools_configured"): - return None - tools = common_utils.fabric_config(payload).get("tools") - if tools is not None and not isinstance(tools, (list, dict)): - raise AdapterConfigError("claude_invalid_configuration", "tools is invalid") - if isinstance(tools, list): - normalized = _string_list(tools, name="tools") - if include_skills and "Skill" not in normalized: - normalized.append("Skill") - return normalized - if isinstance(tools, dict) and "blocked" in tools: - return None - if isinstance(tools, dict) and tools != {"type": "preset", "preset": "claude_code"}: - raise AdapterConfigError( - "claude_invalid_configuration", - "tools preset must be {'type': 'preset', 'preset': 'claude_code'}", - ) - return tools - - def _disallowed_tools(payload: dict[str, Any], settings: dict[str, Any]) -> list[str]: return common_utils.merge_unique( common_utils.blocked_tools(payload), @@ -322,7 +300,7 @@ def build_options(payload: dict[str, Any], *, resume: str | None) -> ClaudeAgent cwd=resolve_cwd(payload), model=selected_model(payload), system_prompt=system_prompt, - tools=_normalized_tools(payload, include_skills=bool(plugins)), + tools=None, allowed_tools=_string_list(settings.get("allowed_tools"), name="allowed_tools"), disallowed_tools=_disallowed_tools(payload, settings), permission_mode=permission_mode, diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index a0e909bfa..cef3f9dbe 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -82,7 +82,7 @@ per-step events, LangGraph thread id, token usage (and cost when the provider reports it), and errors. Usage aggregates the current turn across the main agent and any delegated subagents (streamed with `subgraphs=True`). Configuration and preflight failures (a missing credential, an absent `deepagents` package, an -invalid allow-list, MCP server, or passthrough option) are returned as a +invalid MCP server, or passthrough option) are returned as a normalized failure result rather than a raw traceback. ## Runtime Modes diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 1fc6f2613..54bb44405 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -207,23 +207,6 @@ async def resolve_tools(payload: dict[str, Any]) -> list[Any] | None: return tools or None -def _allowed_tool_names(payload: dict[str, Any]) -> set[str] | None: - # The routed plan only marks ``native.tools_configured``; the value lives in - # ``effective_config.config.tools``. Return ``None`` only when tools are not - # configured; an explicitly empty list is a deny-all allow-list, not "no list". - # A wrong-shaped value must fail loudly rather than silently disable gating. - tools = common_utils.fabric_config(payload).get("tools") - if tools is None: - return None - if isinstance(tools, dict) and "blocked" in tools: - return None - if not isinstance(tools, (list, str)): - raise AdapterConfigError( - f"config.tools must be a list of tool names (a deny/allow-list), not {type(tools).__name__}." - ) - return set(common_utils.normalize_list(tools)) - - def _blocked_tool_names(payload: dict[str, Any]) -> set[str]: return set(common_utils.blocked_tools(payload)) @@ -254,21 +237,6 @@ def wrap_tool_call(self, request: Any, handler: Any) -> Any: return ToolGateMiddleware() -def allowed_tools_middleware(allowed: set[str]) -> Any: - """Middleware that blocks tool calls whose name is not in the allow-list. - - Enforces Fabric's ``config.tools`` allow-list across the *full* tool surface - (Deep Agents built-ins such as ``write_file``/``execute``/``task`` and MCP - tools alike), since the built-ins are contributed by middleware rather than the - ``tools`` argument and cannot be pre-filtered out of the ``tools`` list. - """ - - return _tool_gate_middleware( - lambda name: name not in allowed, - lambda name: f"Tool '{name}' is not permitted by the configured tools allow-list.", - ) - - def blocked_tools_middleware(blocked: set[str]) -> Any: """Middleware that blocks explicitly denied tool calls across the full tool surface.""" @@ -406,19 +374,7 @@ async def build_agent_kwargs(payload: dict[str, Any], model: Any, settings: dict extra = settings.get("deepagents") if extra is not None: kwargs.update(_validated_passthrough(extra)) - allowed = _allowed_tool_names(payload) blocked = _blocked_tool_names(payload) - if allowed is not None: - # Gate the main agent AND every configured subagent, so the allow-list - # covers the full tool surface. The built-in ``task`` tool (which spawns - # the general-purpose subagent) is itself gated on the main agent, so a - # list that omits ``task`` blocks all delegation. - middleware = list(kwargs.get("middleware") or []) - middleware.append(allowed_tools_middleware(allowed)) - kwargs["middleware"] = middleware - subagents = kwargs.get("subagents") - if isinstance(subagents, list): - kwargs["subagents"] = [_gate_subagent(sub, allowed) for sub in subagents] if blocked: middleware = list(kwargs.get("middleware") or []) middleware.append(blocked_tools_middleware(blocked)) @@ -457,14 +413,6 @@ def _validated_passthrough(extra: Any) -> dict[str, Any]: return dict(extra) -def _gate_subagent(subagent: Any, allowed: set[str]) -> Any: - if not isinstance(subagent, dict): - return subagent - gated = dict(subagent) - gated["middleware"] = [*(gated.get("middleware") or []), allowed_tools_middleware(allowed)] - return gated - - def _block_subagent(subagent: Any, blocked: set[str]) -> Any: if not isinstance(subagent, dict): return subagent diff --git a/crates/fabric-core/src/doctor.rs b/crates/fabric-core/src/doctor.rs index ab5c5274e..6b0b14724 100644 --- a/crates/fabric-core/src/doctor.rs +++ b/crates/fabric-core/src/doctor.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::config::{ - AdapterKind, CapabilityTarget, ControlLocation, EnvironmentOwnership, ResolutionStrategy, - RunPlan, + AdapterKind, CapabilityKind, CapabilityTarget, ControlLocation, EnvironmentOwnership, + ResolutionStrategy, RunPlan, }; /// Diagnostic status. @@ -186,9 +186,14 @@ fn check_capability_routes(plan: &RunPlan) -> Vec { .iter() .filter(|route| route.target == CapabilityTarget::Unsupported) .map(|route| { + let status = if route.kind == CapabilityKind::Tools { + DoctorStatus::Fail + } else { + DoctorStatus::Warn + }; check( "capability.unsupported", - DoctorStatus::Warn, + status, format!( "{:?} capability `{}` is configured but not executable: {}", route.kind, route.name, route.reason @@ -469,7 +474,10 @@ mod tests { use serde_json::Value; use super::*; - use crate::config::{AdapterKind, ResolutionStrategy, resolve_run_plan}; + use crate::config::{ + AdapterKind, CapabilityKind, CapabilityRoute, CapabilityTarget, ResolutionStrategy, + resolve_run_plan, + }; fn file_config_agent_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/file-config-agent") @@ -572,4 +580,24 @@ mod tests { && check.message.contains("service") })); } + + #[test] + fn unsupported_blocked_tools_fail_doctor() { + let mut plan = resolve_run_plan(file_config_agent_dir(), None).expect("run plan"); + plan.capability_plan.routes.push(CapabilityRoute { + kind: CapabilityKind::Tools, + name: "blocked".to_string(), + target: CapabilityTarget::Unsupported, + reason: "adapter does not accept tools".to_string(), + }); + + let report = doctor_plan(&plan); + + assert_eq!(report.status, DoctorStatus::Fail); + assert!(report.checks.iter().any(|check| { + check.name == "capability.unsupported" + && check.status == DoctorStatus::Fail + && check.message.contains("adapter does not accept tools") + })); + } } diff --git a/crates/fabric-core/src/error.rs b/crates/fabric-core/src/error.rs index f2656233b..c98664c58 100644 --- a/crates/fabric-core/src/error.rs +++ b/crates/fabric-core/src/error.rs @@ -109,6 +109,14 @@ pub enum FabricError { /// Adapter kind. adapter_kind: AdapterKind, }, + /// The selected harness cannot enforce the configured blocked-tools policy. + #[error("harness `{harness}` cannot enforce configured blocked tools: {reason}")] + UnsupportedToolsPolicy { + /// Harness type. + harness: String, + /// Capability-routing explanation. + reason: String, + }, /// A runtime handle was used with a different run plan than the one that created it. #[error( "runtime handle does not match run plan for `{field}`: expected `{expected}` but found `{actual}` (runtime `{runtime_id}`)" diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 54f79c4fe..10f9bd554 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::config::{ - AdapterKind, CapabilityPlan, ControlLocation, EffectiveConfig, EnvironmentOwnership, RunPlan, - TelemetryPlan, + AdapterKind, CapabilityKind, CapabilityPlan, CapabilityTarget, ControlLocation, + EffectiveConfig, EnvironmentOwnership, RunPlan, TelemetryPlan, }; use crate::error::{FabricError, Result}; @@ -408,6 +408,7 @@ pub fn prepare_environment(plan: &RunPlan) -> Result { /// Start or connect to a harness runtime. pub fn start_runtime(plan: &RunPlan) -> Result { + validate_blocked_tools_support(plan)?; let environment = prepare_environment(plan)?; match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.start(plan, environment), @@ -425,6 +426,7 @@ pub fn invoke_runtime( runtime: &RuntimeHandle, request: RunRequest, ) -> Result { + validate_blocked_tools_support(plan)?; validate_runtime_handle(plan, runtime)?; match adapter_kind(plan) { AdapterKind::Process => ProcessAdapter.invoke(plan, runtime, request), @@ -436,6 +438,18 @@ pub fn invoke_runtime( } } +fn validate_blocked_tools_support(plan: &RunPlan) -> Result<()> { + if let Some(route) = plan.capability_plan.routes.iter().find(|route| { + route.kind == CapabilityKind::Tools && route.target == CapabilityTarget::Unsupported + }) { + return Err(FabricError::UnsupportedToolsPolicy { + harness: harness(plan), + reason: route.reason.clone(), + }); + } + Ok(()) +} + /// Stop or detach from a harness runtime. pub fn stop_runtime(plan: &RunPlan, runtime: &RuntimeHandle) -> Result> { validate_runtime_handle(plan, runtime)?; @@ -2036,6 +2050,26 @@ environment: let _ = fs::remove_dir_all(root); } + #[test] + fn runtime_rejects_blocked_tools_when_adapter_cannot_enforce_them() { + let root = temp_process_agent_dir(); + let config_path = root.join("agent.yaml"); + let mut config = fs::read_to_string(&config_path).expect("read config"); + config.push_str("tools:\n blocked:\n - shell\n"); + fs::write(&config_path, config).expect("write blocked tools config"); + let plan = resolve_run_plan(&root, None).expect("run plan"); + + let error = start_runtime(&plan).expect_err("unsupported tools policy must fail closed"); + + assert!(matches!(error, FabricError::UnsupportedToolsPolicy { .. })); + assert!( + error + .to_string() + .contains("cannot enforce configured blocked tools") + ); + let _ = fs::remove_dir_all(root); + } + #[test] fn independent_runtimes_use_distinct_artifact_paths() { let root = temp_process_agent_dir(); diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 1c8b4dd08..540cb5820 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -1422,6 +1422,7 @@ Return a detached JSON-compatible mapping for Rust/core calls. ## class `ToolsConfig` + Harness-neutral tool capability configuration. diff --git a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx index 9c40ce633..55f417b5a 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/error/enum-fabricerror.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p fabric-core`. -
PathBuf),\n    MissingEntrypoint(PathBuf),\n    UnsupportedExtension(PathBuf),\n    UnknownProfile {\n        profile: String,\n        agent: String,\n        available: Vec<String>,\n    },\n    ProfileError {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    ProfileTargetNotConfig {\n        profile: String,\n        path: PathBuf,\n    },\n    ProfileSelectionNotSupported(PathBuf),\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidAdapterPython {\n        value: String,\n        path: PathBuf,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseYaml {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
+
PathBuf),\n    MissingEntrypoint(PathBuf),\n    UnsupportedExtension(PathBuf),\n    UnknownProfile {\n        profile: String,\n        agent: String,\n        available: Vec<String>,\n    },\n    ProfileError {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    ProfileTargetNotConfig {\n        profile: String,\n        path: PathBuf,\n    },\n    ProfileSelectionNotSupported(PathBuf),\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedToolsPolicy {\n        harness: String,\n        reason: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidAdapterPython {\n        value: String,\n        path: PathBuf,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseYaml {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
Errors raised by Fabric config loading and validation. @@ -199,6 +199,22 @@ Harness type. Adapter kind. +### `UnsupportedToolsPolicy` + +
+ +The selected harness cannot enforce the configured blocked-tools policy. + +#### Fields + +### `harness: String` + +Harness type. + +### `reason: String` + +Capability-routing explanation. + ### `RuntimeHandleMismatch`
diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 811401074..3ec6455c6 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -39,6 +39,8 @@ perl -0pi -e 's/\A\s+//' "$out"/*.md # lazydocs nests properties at h4 directly under h2 class sections. Flatten # those headings to h3 so generated pages satisfy markdown heading order. perl -pi -e 's/^#### (property<\/kbd>)/### $1/' "$out"/*.md +# lazydocs emits the ToolsConfig class heading without a separating blank line. +perl -0pi -e 's/(^## class<\/kbd> `ToolsConfig`\n)(?!\n)/$1\n/m' "$out/nemo_fabric.models.md" add_frontmatter() { local file="$1" diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index e94287a99..0aa20e924 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -87,7 +87,7 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "api_key_env": "ANTHROPIC_API_KEY", } }, - "tools": ["Read", "Glob", "Grep"], + "tools": {"blocked": ["Bash"]}, }, }, "runtime_context": { @@ -125,9 +125,9 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert options.cwd == Path(claude_payload["runtime_context"]["environment"]["workspace"]) assert options.model == "claude-test-model" assert options.system_prompt == "Review carefully." - assert options.tools == ["Read", "Glob", "Grep", "Skill"] + assert options.tools is None assert options.allowed_tools == ["Read"] - assert options.disallowed_tools == ["WebFetch"] + assert options.disallowed_tools == ["Bash", "WebFetch"] assert options.permission_mode == "dontAsk" assert options.max_turns == 4 assert options.max_budget_usd == 1.5 @@ -183,16 +183,6 @@ def test_build_options_rejects_skill_path_without_skill_manifest(claude_payload) adapter.build_options(claude_payload, resume=None) -def test_build_options_rejects_unknown_normalized_tool_preset(claude_payload): - claude_payload["effective_config"]["config"]["tools"] = { - "type": "preset", - "preset": "unknown", - } - - with pytest.raises(adapter.AdapterConfigError, match="tools preset"): - adapter.build_options(claude_payload, resume=None) - - def test_selected_model_rejects_unsupported_provider(claude_payload): model = claude_payload["effective_config"]["config"]["models"]["default"] model["provider"] = "nvidia" @@ -201,14 +191,6 @@ def test_selected_model_rejects_unsupported_provider(claude_payload): adapter.selected_model(claude_payload) -def test_build_options_ignores_tools_not_routed_to_adapter(claude_payload): - claude_payload["capability_plan"]["native"]["tools_configured"] = False - - options = adapter.build_options(claude_payload, resume=None) - - assert options.tools is None - - def test_state_round_trip_is_keyed_by_fabric_runtime(claude_payload): runtime_id = adapter.runtime_id(claude_payload) adapter.save_claude_session_id(claude_payload, runtime_id, "claude-session") diff --git a/tests/adapters/test_codex_cli.py b/tests/adapters/test_codex_cli.py index f97cc170c..286acec83 100644 --- a/tests/adapters/test_codex_cli.py +++ b/tests/adapters/test_codex_cli.py @@ -577,7 +577,6 @@ def test_runtime_reuses_codex_thread_across_invocations(codex_payload, monkeypat child_env = mock_run.call_args_list[0].kwargs["env"] assert child_env["CODEX_HOME"] == str(tmp_path / "codex-home") assert child_env["CODEX_EXPLICIT"] == "forward-me" - assert "FABRIC_UNRELATED_SECRET" not in child_env assert "OPENAI_API_KEY" not in child_env assert mock_run.call_args_list[0].kwargs["timeout"] == 1800 diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 3e34890b1..8dc91777f 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -369,7 +369,7 @@ def boom(**_kwargs): assert fake_sdks["saver_exits"] == 2 -async def test_mcp_servers_become_tools_filtered_by_allowed(tmp_path, make_payload, monkeypatch, fake_sdks): +async def test_mcp_servers_become_adapter_tools(tmp_path, make_payload, monkeypatch, fake_sdks): tool_read = MagicMock() tool_read.name = "read_file" tool_write = MagicMock() @@ -406,47 +406,6 @@ async def test_mcp_servers_become_tools_filtered_by_allowed(tmp_path, make_paylo assert tool_names == ["read_file", "write_file"] -@pytest.mark.usefixtures("use_real_langgraph") -async def test_allowed_tools_recorded_as_middleware(tmp_path, make_payload, fake_sdks): - # An allow-list is enforced by a gating middleware over the full tool surface, - # not by filtering the passed tools list. - pytest.importorskip("langchain.agents.middleware") - pytest.importorskip("langgraph.checkpoint.sqlite.aio") - payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = ["read_file"] - - await adapter.run_deepagents(payload) - - assert fake_sdks["create_kwargs"]["middleware"], "gating middleware not attached" - - -@pytest.mark.usefixtures("use_real_langgraph") -async def test_allowed_tools_middleware_blocks_disallowed_tools(): - pytest.importorskip("langchain.agents.middleware") - from langchain_core.messages import ToolMessage - - middleware = adapter.allowed_tools_middleware({"read_file"}) - - async def handler(_request): - return "executed" - - def request(name): - return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) - - blocked = await middleware.awrap_tool_call(request("write_file"), handler) - assert isinstance(blocked, ToolMessage) - assert blocked.status == "error" - - allowed = await middleware.awrap_tool_call(request("read_file"), handler) - assert allowed == "executed" - - # An explicitly empty allow-list denies every tool. - deny_all = adapter.allowed_tools_middleware(set()) - denied = await deny_all.awrap_tool_call(request("read_file"), handler) - assert isinstance(denied, ToolMessage) - assert denied.status == "error" - - @pytest.mark.usefixtures("use_real_langgraph") async def test_blocked_tools_middleware_blocks_configured_tools(): pytest.importorskip("langchain.agents.middleware") @@ -454,10 +413,10 @@ async def test_blocked_tools_middleware_blocks_configured_tools(): middleware = adapter.blocked_tools_middleware({"write_file"}) - async def handler(_request): + async def handler(_request: Any) -> str: return "executed" - def request(name): + def request(name: str) -> Any: return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) blocked = await middleware.awrap_tool_call(request("write_file"), handler) @@ -468,12 +427,6 @@ def request(name): assert allowed == "executed" -def test_empty_tools_is_deny_all_not_none(): - # An explicitly empty tools list is a deny-all allow-list, not "no allow-list". - assert adapter._allowed_tool_names({"effective_config": {"config": {"tools": []}}}) == set() - assert adapter._allowed_tool_names({"effective_config": {"config": {}}}) is None - - @pytest.mark.usefixtures("use_real_langgraph") async def test_real_langgraph_async_checkpointer(tmp_path, make_payload, monkeypatch): # Regression: driving astream with the sync SqliteSaver raises NotImplementedError. @@ -620,39 +573,36 @@ async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): assert fake_sdks["subgraphs"] is True -async def test_subagents_are_gated_by_allow_list(tmp_path, make_payload, fake_sdks): - # The allow-list must gate delegated subagents too, not only the main agent, - # so tools routed through the ``task`` tool cannot run ungated. - payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = ["read_file"] - payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { - "subagents": [{"name": "researcher", "prompt": "research"}] - } - - await adapter.run_deepagents(payload) - - create_kwargs = fake_sdks["create_kwargs"] - assert create_kwargs["middleware"], "main agent gating middleware not attached" - subagents = create_kwargs["subagents"] - assert len(subagents) == 1 - assert subagents[0]["middleware"], "subagent gating middleware not attached" - - +@pytest.mark.usefixtures("use_real_langgraph") async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload, fake_sdks): + pytest.importorskip("langchain.agents.middleware") + from langchain_core.messages import ToolMessage + payload = make_payload(tmp_path) payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { "subagents": [{"name": "researcher", "prompt": "research"}] } - await adapter.run_deepagents(payload) - - create_kwargs = fake_sdks["create_kwargs"] + settings = payload["effective_config"]["config"]["harness"]["settings"] + create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) assert create_kwargs["middleware"], "main agent blocked-tools middleware not attached" subagents = create_kwargs["subagents"] assert len(subagents) == 1 assert subagents[0]["middleware"], "subagent blocked-tools middleware not attached" + async def handler(_request: Any) -> str: + return "executed" + + def request(name: str) -> Any: + return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) + + for middleware in (create_kwargs["middleware"][-1], subagents[0]["middleware"][-1]): + blocked = await middleware.awrap_tool_call(request("write_file"), handler) + assert isinstance(blocked, ToolMessage) + assert blocked.status == "error" + assert await middleware.awrap_tool_call(request("read_file"), handler) == "executed" + async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): # Documented JSON-serializable options reach create_deep_agent unchanged. @@ -732,17 +682,6 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): assert any(evt.get("subgraph") == "task:researcher" for evt in output["events"]) -async def test_dict_tools_is_normalized_failure(tmp_path, make_payload): - # A dict-shaped tools value must fail loudly, not silently disable gating. - payload = make_payload(tmp_path) - payload["effective_config"]["config"]["tools"] = {"read_file": True} - - output = await adapter.run_deepagents(payload) - - assert output["failed"] is True - assert "tools" in output["error"] - - async def test_bad_mcp_transport_is_normalized_failure(tmp_path, make_payload): # A misconfigured MCP server must fail loudly, not be silently dropped. payload = make_payload(tmp_path) From 4a59a66e8c7f9fff4578a32784bd855c665af2fe Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:23:41 -0700 Subject: [PATCH 4/5] fix(tools): address blocked policy review feedback Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/claude/fabric-adapter.json | 2 +- adapters/deepagents/README.md | 16 +++--- adapters/deepagents/fabric-adapter.json | 2 +- .../deepagents/adapter.py | 36 ++++++++++-- adapters/hermes/fabric-adapter.json | 1 + crates/fabric-core/src/config.rs | 38 ++++++++++--- crates/fabric-core/src/runtime.rs | 11 ++++ .../config/struct-adapterconfigsupport.mdx | 2 +- schemas/adapter-descriptor.schema.json | 4 +- schemas/run-plan.schema.json | 4 +- tests/adapters/test_claude_adapter.py | 13 ++++- tests/adapters/test_deepagents.py | 55 ++++++++++++++++--- 12 files changed, 145 insertions(+), 39 deletions(-) diff --git a/adapters/claude/fabric-adapter.json b/adapters/claude/fabric-adapter.json index 03406131e..ed36b146a 100644 --- a/adapters/claude/fabric-adapter.json +++ b/adapters/claude/fabric-adapter.json @@ -8,7 +8,7 @@ "callable": "run" }, "config": { - "accepts": ["models", "tools", "mcp", "skills", "telemetry"] + "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] }, "telemetry": { "providers": { diff --git a/adapters/deepagents/README.md b/adapters/deepagents/README.md index cef3f9dbe..e233d1ba7 100644 --- a/adapters/deepagents/README.md +++ b/adapters/deepagents/README.md @@ -69,20 +69,20 @@ Fabric maps the following into the harness: Deep Agents can delegate to subagents through its built-in `task` tool. Subagents **inherit** the parent run's model, tools, skills, workspace, telemetry, and -permissions — in particular, the parent `tools.blocked` policy applies to -delegated execution, so a subagent cannot broaden capabilities beyond the parent. -Independently configured subagent tools, skills, models, MCP servers, middleware, -or permissions are **not** exposed through the Fabric SDK yet; a `subagents` -definition here only carries JSON-shaped fields. Deterministic verification of -delegated tool-gating is currently mock-based (see the adapter tests); a fuller -real-subagent contract is future work. +permissions. When `tools.blocked` is configured, Fabric supplies an explicitly +gated `general-purpose` subagent and gates every declarative local subagent, so +delegation cannot broaden capabilities beyond the parent. Remote and precompiled +subagents are rejected in that case because their execution cannot be governed by +the local middleware. Independently configured subagent tools, skills, models, +MCP servers, middleware, or permissions are **not** exposed through the Fabric SDK +yet; a `subagents` definition here only carries JSON-shaped fields. The normalized result includes the final response, buffered messages and per-step events, LangGraph thread id, token usage (and cost when the provider reports it), and errors. Usage aggregates the current turn across the main agent and any delegated subagents (streamed with `subgraphs=True`). Configuration and preflight failures (a missing credential, an absent `deepagents` package, an -invalid MCP server, or passthrough option) are returned as a +invalid MCP server, or a passthrough option) are returned as a normalized failure result rather than a raw traceback. ## Runtime Modes diff --git a/adapters/deepagents/fabric-adapter.json b/adapters/deepagents/fabric-adapter.json index c69e3b842..78a09fe35 100644 --- a/adapters/deepagents/fabric-adapter.json +++ b/adapters/deepagents/fabric-adapter.json @@ -9,7 +9,7 @@ }, "requirements": {}, "config": { - "accepts": ["models", "tools", "mcp", "skills", "telemetry"] + "accepts": ["models", "tools", "tools.blocked", "mcp", "skills", "telemetry"] }, "telemetry": { "providers": { diff --git a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 54bb44405..f35e9c709 100644 --- a/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -379,9 +379,7 @@ async def build_agent_kwargs(payload: dict[str, Any], model: Any, settings: dict middleware = list(kwargs.get("middleware") or []) middleware.append(blocked_tools_middleware(blocked)) kwargs["middleware"] = middleware - subagents = kwargs.get("subagents") - if isinstance(subagents, list): - kwargs["subagents"] = [_block_subagent(sub, blocked) for sub in subagents] + kwargs["subagents"] = _gated_subagents(kwargs.get("subagents"), blocked) return {key: value for key, value in kwargs.items() if value is not None} @@ -413,14 +411,40 @@ def _validated_passthrough(extra: Any) -> dict[str, Any]: return dict(extra) -def _block_subagent(subagent: Any, blocked: set[str]) -> Any: - if not isinstance(subagent, dict): - return subagent +def _block_subagent(subagent: dict[str, Any], blocked: set[str]) -> dict[str, Any]: gated = dict(subagent) gated["middleware"] = [*(gated.get("middleware") or []), blocked_tools_middleware(blocked)] return gated +def _gated_subagents(subagents: Any, blocked: set[str]) -> list[dict[str, Any]]: + if subagents is None: + configured: list[Any] = [] + elif isinstance(subagents, list): + configured = subagents + else: + raise AdapterConfigError( + "harness.settings.deepagents.subagents must be a list when tools.blocked is configured." + ) + + gated: list[dict[str, Any]] = [] + for subagent in configured: + if not isinstance(subagent, dict): + raise AdapterConfigError("Deep Agents subagents must be mappings when tools.blocked is configured.") + name = str(subagent.get("name") or "") + if "graph_id" in subagent: + raise AdapterConfigError(f"tools.blocked cannot be enforced for remote Deep Agents subagent '{name}'.") + if "runnable" in subagent: + raise AdapterConfigError(f"tools.blocked cannot be enforced for precompiled Deep Agents subagent '{name}'.") + gated.append(_block_subagent(subagent, blocked)) + + if not any(subagent.get("name") == "general-purpose" for subagent in gated): + from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT + + gated.insert(0, _block_subagent(dict(GENERAL_PURPOSE_SUBAGENT), blocked)) + return gated + + async def run_deepagents(payload: dict[str, Any]) -> dict[str, Any]: settings = common_utils.settings_payload(payload) request = payload.get("request") or {} diff --git a/adapters/hermes/fabric-adapter.json b/adapters/hermes/fabric-adapter.json index 59dd0e0dd..88a41040b 100644 --- a/adapters/hermes/fabric-adapter.json +++ b/adapters/hermes/fabric-adapter.json @@ -15,6 +15,7 @@ "config": { "accepts": [ "tools", + "tools.blocked", "mcp", "skills", "telemetry" diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 9df6af9e2..a40d3abce 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -358,7 +358,7 @@ pub struct AdapterRequirements { /// Adapter config support. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AdapterConfigSupport { - /// Fabric config areas accepted by this adapter. + /// Fabric config areas or policy paths accepted by this adapter. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub accepts: Vec, /// Harness-native files generated by this adapter. @@ -1610,7 +1610,7 @@ fn resolve_capability_plan( .map(|tools| tools.blocked.clone()) .unwrap_or_default(); let tools_configured = !blocked_tools.is_empty(); - let tools_are_native = tools_configured && accepts("tools"); + let tools_are_native = tools_configured && accepts("tools.blocked"); let mut native = CapabilityTargetPlan::default(); let managed = CapabilityTargetPlan::default(); let mut unsupported = CapabilityTargetPlan::default(); @@ -1621,17 +1621,18 @@ fn resolve_capability_plan( native.tools_configured = true; routes.push(CapabilityRoute { kind: CapabilityKind::Tools, - name: "tools".to_string(), + name: "tools.blocked".to_string(), target: CapabilityTarget::HarnessNative, - reason: "selected adapter accepts Fabric tools config".to_string(), + reason: "selected adapter explicitly supports the Fabric blocked-tools policy" + .to_string(), }); } else { unsupported.tools_configured = true; routes.push(CapabilityRoute { kind: CapabilityKind::Tools, - name: "tools".to_string(), + name: "tools.blocked".to_string(), target: CapabilityTarget::Unsupported, - reason: "selected adapter does not declare native tools support and Fabric-managed tools are not implemented".to_string(), + reason: "selected adapter does not explicitly declare blocked-tools policy support and Fabric-managed enforcement is not implemented".to_string(), }); } } @@ -2706,7 +2707,7 @@ mcp: } #[test] - fn blocked_tools_route_to_native_only_when_non_empty_and_supported() { + fn blocked_tools_require_policy_specific_adapter_support() { let root = std::env::temp_dir().join(format!( "fabric-blocked-tools-capability-test-{}", std::process::id() @@ -2743,8 +2744,31 @@ tools: assert_eq!(plan.capability_plan.tools.blocked, vec!["browser"]); assert!(plan.capability_plan.tools_configured); + assert!(!plan.capability_plan.native.tools_configured); + assert!(plan.capability_plan.unsupported.tools_configured); + assert!(plan.capability_plan.routes.iter().any(|route| { + route.name == "tools.blocked" && route.target == CapabilityTarget::Unsupported + })); + + std::fs::write( + root.join("adapters/tools/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.tools", + "harness": "tools", + "adapter_kind": "process", + "config": {"accepts": ["tools", "tools.blocked"]} +}"#, + ) + .expect("write policy-aware adapter descriptor"); + + let plan = resolve_run_plan(&root, None).expect("policy-aware run plan"); + assert!(plan.capability_plan.native.tools_configured); assert!(!plan.capability_plan.unsupported.tools_configured); + assert!(plan.capability_plan.routes.iter().any(|route| { + route.name == "tools.blocked" && route.target == CapabilityTarget::HarnessNative + })); std::fs::write( root.join("agent.yaml"), diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 10f9bd554..c2c11523d 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -2057,6 +2057,17 @@ environment: let mut config = fs::read_to_string(&config_path).expect("read config"); config.push_str("tools:\n blocked:\n - shell\n"); fs::write(&config_path, config).expect("write blocked tools config"); + fs::write( + root.join("adapters/process/fabric-adapter.json"), + r#"{ + "contract_version": "fabric.adapter/v1alpha1", + "adapter_id": "acme.fabric.process", + "harness": "process", + "adapter_kind": "process", + "config": {"accepts": ["tools"]} +}"#, + ) + .expect("write generic tools descriptor"); let plan = resolve_run_plan(&root, None).expect("run plan"); let error = start_runtime(&plan).expect_err("unsupported tools policy must fail closed"); diff --git a/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx b/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx index c5df2a07b..d300e4079 100644 --- a/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx +++ b/docs/reference/api/rust-library-reference/fabric-core/config/struct-adapterconfigsupport.mdx @@ -17,7 +17,7 @@ Adapter config support. ### `accepts: Vec` -Fabric config areas accepted by this adapter. +Fabric config areas or policy paths accepted by this adapter. ### `generates: Vec` diff --git a/schemas/adapter-descriptor.schema.json b/schemas/adapter-descriptor.schema.json index 9362e01f2..7eb806702 100644 --- a/schemas/adapter-descriptor.schema.json +++ b/schemas/adapter-descriptor.schema.json @@ -5,7 +5,7 @@ "description": "Adapter config support.", "properties": { "accepts": { - "description": "Fabric config areas accepted by this adapter.", + "description": "Fabric config areas or policy paths accepted by this adapter.", "items": { "type": "string" }, @@ -217,4 +217,4 @@ ], "title": "AdapterDescriptor", "type": "object" -} \ No newline at end of file +} diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 0cff14568..a5f6efd4e 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -5,7 +5,7 @@ "description": "Adapter config support.", "properties": { "accepts": { - "description": "Fabric config areas accepted by this adapter.", + "description": "Fabric config areas or policy paths accepted by this adapter.", "items": { "type": "string" }, @@ -1748,4 +1748,4 @@ ], "title": "RunPlan", "type": "object" -} \ No newline at end of file +} diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index 8e8354fd8..f137a3fef 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -40,7 +40,16 @@ def test_claude_descriptor_is_narrow_and_versioned(): "module": "nemo_fabric_adapters.claude.adapter", "callable": "run", }, - "config": {"accepts": ["models", "tools", "mcp", "skills", "telemetry"]}, + "config": { + "accepts": [ + "models", + "tools", + "tools.blocked", + "mcp", + "skills", + "telemetry", + ] + }, "telemetry": { "providers": { "relay": { @@ -298,7 +307,7 @@ def test_build_options_does_not_enable_skills_for_relay_plugin_alone( options = adapter.build_options(relay_payload, resume=None, relay=relay) - assert options.tools == ["Read", "Glob", "Grep"] + assert options.tools is None assert options.skills is None assert options.plugins == [{"type": "local", "path": str(relay.plugin_path)}] diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 1b7f8df5c..86d486965 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -20,7 +20,6 @@ from unittest.mock import MagicMock import pytest - from nemo_fabric_adapters.deepagents import adapter # noqa: E402 @@ -68,9 +67,20 @@ async def astream(inputs, config=None, *, stream_mode=None, subgraphs=False): deepagents_mod.create_deep_agent = MagicMock(side_effect=build_agent) backends_mod = types.ModuleType("deepagents.backends") backends_mod.FilesystemBackend = mock_fs_backend + middleware_mod = types.ModuleType("deepagents.middleware") + subagents_mod = types.ModuleType("deepagents.middleware.subagents") + subagents_mod.GENERAL_PURPOSE_SUBAGENT = { + "name": "general-purpose", + "description": "General-purpose delegated agent.", + "system_prompt": "Handle the delegated task.", + } deepagents_mod.backends = backends_mod + deepagents_mod.middleware = middleware_mod + middleware_mod.subagents = subagents_mod monkeypatch.setitem(sys.modules, "deepagents", deepagents_mod) monkeypatch.setitem(sys.modules, "deepagents.backends", backends_mod) + monkeypatch.setitem(sys.modules, "deepagents.middleware", middleware_mod) + monkeypatch.setitem(sys.modules, "deepagents.middleware.subagents", subagents_mod) langchain_openai_mod = types.ModuleType("langchain_openai") langchain_openai_mod.ChatOpenAI = mock_chat_openai @@ -407,10 +417,10 @@ async def test_blocked_tools_middleware_blocks_configured_tools(): middleware = adapter.blocked_tools_middleware({"write_file"}) - async def handler(_request: Any) -> str: + async def handler(_request: types.SimpleNamespace) -> str: return "executed" - def request(name: str) -> Any: + def request(name: str) -> types.SimpleNamespace: return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) blocked = await middleware.awrap_tool_call(request("write_file"), handler) @@ -568,7 +578,7 @@ async def test_stream_requests_subgraphs(tmp_path, make_payload, fake_sdks): @pytest.mark.usefixtures("use_real_langgraph") -async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload, fake_sdks): +async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload): pytest.importorskip("langchain.agents.middleware") from langchain_core.messages import ToolMessage @@ -582,22 +592,49 @@ async def test_subagents_are_gated_by_blocked_tools(tmp_path, make_payload, fake create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) assert create_kwargs["middleware"], "main agent blocked-tools middleware not attached" subagents = create_kwargs["subagents"] - assert len(subagents) == 1 - assert subagents[0]["middleware"], "subagent blocked-tools middleware not attached" + assert [subagent["name"] for subagent in subagents] == ["general-purpose", "researcher"] + assert all(subagent["middleware"] for subagent in subagents) - async def handler(_request: Any) -> str: + async def handler(_request: types.SimpleNamespace) -> str: return "executed" - def request(name: str) -> Any: + def request(name: str) -> types.SimpleNamespace: return types.SimpleNamespace(tool_call={"name": name, "id": "call-1", "args": {}}) - for middleware in (create_kwargs["middleware"][-1], subagents[0]["middleware"][-1]): + gates = [create_kwargs["middleware"][-1]] + gates.extend(subagent["middleware"][-1] for subagent in subagents) + for middleware in gates: blocked = await middleware.awrap_tool_call(request("write_file"), handler) assert isinstance(blocked, ToolMessage) assert blocked.status == "error" assert await middleware.awrap_tool_call(request("read_file"), handler) == "executed" +@pytest.mark.usefixtures("use_real_langgraph") +async def test_default_subagent_is_gated_by_blocked_tools(tmp_path, make_payload): + payload = make_payload(tmp_path) + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} + + settings = payload["effective_config"]["config"]["harness"]["settings"] + create_kwargs = await adapter.build_agent_kwargs(payload, MagicMock(), settings) + + assert [subagent["name"] for subagent in create_kwargs["subagents"]] == ["general-purpose"] + assert create_kwargs["subagents"][0]["middleware"] + + +@pytest.mark.parametrize("unsupported", [{"graph_id": "remote"}, {"runnable": "compiled"}]) +async def test_blocked_tools_reject_unenforceable_subagents(tmp_path, make_payload, unsupported): + payload = make_payload(tmp_path) + payload["effective_config"]["config"]["tools"] = {"blocked": ["write_file"]} + payload["effective_config"]["config"]["harness"]["settings"]["deepagents"] = { + "subagents": [{"name": "worker", **unsupported}] + } + + settings = payload["effective_config"]["config"]["harness"]["settings"] + with pytest.raises(adapter.AdapterConfigError, match="cannot be enforced"): + await adapter.build_agent_kwargs(payload, MagicMock(), settings) + + async def test_deepagents_passthrough_forwards_supported_options(tmp_path, make_payload, fake_sdks): # Documented JSON-serializable options reach create_deep_agent unchanged. payload = make_payload(tmp_path) From bd83a3b74749439ffc744d208f88ce57fd6a56ea Mon Sep 17 00:00:00 2001 From: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:41:45 -0700 Subject: [PATCH 5/5] refactor(claude): require normalized blocked tools Signed-off-by: Anuradha Karuppiah <26330987+AnuradhaKaruppiah@users.noreply.github.com> --- adapters/claude/README.md | 11 +++-- .../nemo_fabric_adapters/claude/adapter.py | 41 ++++++++----------- tests/adapters/test_claude_adapter.py | 4 +- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/adapters/claude/README.md b/adapters/claude/README.md index 4ae28d521..fb47c3b08 100644 --- a/adapters/claude/README.md +++ b/adapters/claude/README.md @@ -49,8 +49,7 @@ Configure portable capabilities through the normalized `FabricConfig` fields: tracked in [FABRIC-64](https://linear.app/nvidia/issue/FABRIC-64/add-normalized-model-provider-resolution-and-harness-compatibility). - `environment.workspace` sets the Claude working directory. - `tools.blocked` maps to Claude `disallowed_tools` using Claude-native tool - names. Adapter-specific `harness.settings.disallowed_tools` remains supported; - when both are present, the effective deny-list is the union. + names. - `mcp` configures stdio, HTTP, streamable HTTP, or SSE servers. For stdio, Fabric parses `url` as a command plus arguments. - `skills.paths` names skill directories that contain `SKILL.md`. The adapter @@ -58,16 +57,16 @@ Configure portable capabilities through the normalized `FabricConfig` fields: Only Claude-specific controls belong in `harness.settings`: -- `system_prompt`, `allowed_tools`, `disallowed_tools`, and `permission_mode` +- `system_prompt`, `allowed_tools`, and `permission_mode` - `max_turns`, `max_budget_usd`, and `timeout_seconds` - `setting_sources` (defaults to `[]` for deterministic isolation) - `cli_path` for testing or an explicitly installed Claude Code executable - `nemo_relay_command` for an explicitly installed NeMo Relay CLI executable - `env` for variables explicitly forwarded to Claude Code -Putting `model_name`, `cwd`, `tools`, `mcp_servers`, or `skills` in -`harness.settings` is an error. Use the corresponding normalized field so the -same consumer configuration can compose with other adapters. +Putting `model_name`, `cwd`, `tools`, `disallowed_tools`, `mcp_servers`, or +`skills` in `harness.settings` is an error. Use the corresponding normalized +field so the same consumer configuration can compose with other adapters. The adapter filters the inherited environment before launching Claude Code. It retains portable OS/config variables, the selected model's `api_key_env`, diff --git a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py index 5bad5be8b..a1bb650c9 100644 --- a/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py +++ b/adapters/claude/src/nemo_fabric_adapters/claude/adapter.py @@ -11,27 +11,26 @@ import os import shlex import shutil -from dataclasses import asdict, dataclass, is_dataclass +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import is_dataclass from hashlib import sha256 from pathlib import Path from typing import Any -from claude_agent_sdk import ( - CLIConnectionError, - CLIJSONDecodeError, - CLINotFoundError, - ClaudeAgentOptions, - ClaudeSDKError, - Message, - ProcessError, - ResultMessage, - query, -) +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKError +from claude_agent_sdk import CLIConnectionError +from claude_agent_sdk import CLIJSONDecodeError +from claude_agent_sdk import CLINotFoundError +from claude_agent_sdk import Message +from claude_agent_sdk import ProcessError +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import query from claude_agent_sdk._errors import MessageParseError - -import nemo_fabric_adapters.common.relay_gateway as relay_gateway -import nemo_fabric_adapters.common.relay_hooks as relay_hooks -import nemo_fabric_adapters.common.utils as common_utils +from nemo_fabric_adapters.common import relay_gateway +from nemo_fabric_adapters.common import relay_hooks +from nemo_fabric_adapters.common import utils as common_utils PERMISSION_MODES = { "default", @@ -46,6 +45,7 @@ "model_name": "FabricConfig.models", "cwd": "FabricConfig.environment.workspace", "tools": "FabricConfig.tools", + "disallowed_tools": "FabricConfig.tools.blocked", "mcp_servers": "FabricConfig.mcp", "skills": "FabricConfig.skills", } @@ -266,13 +266,6 @@ def _mcp_servers(payload: dict[str, Any]) -> dict[str, Any]: return result -def _disallowed_tools(payload: dict[str, Any], settings: dict[str, Any]) -> list[str]: - return common_utils.merge_unique( - common_utils.blocked_tools(payload), - _string_list(settings.get("disallowed_tools"), name="disallowed_tools"), - ) - - def _native_skill_paths(payload: dict[str, Any]) -> list[Path]: native = ( _mapping(common_utils.capability_plan(payload), name="capability_plan").get( @@ -493,7 +486,7 @@ def build_options( system_prompt=system_prompt, tools=None, allowed_tools=_string_list(settings.get("allowed_tools"), name="allowed_tools"), - disallowed_tools=_disallowed_tools(payload, settings), + disallowed_tools=common_utils.blocked_tools(payload), permission_mode=permission_mode, max_turns=max_turns, max_budget_usd=max_budget, diff --git a/tests/adapters/test_claude_adapter.py b/tests/adapters/test_claude_adapter.py index f137a3fef..ec9947d95 100644 --- a/tests/adapters/test_claude_adapter.py +++ b/tests/adapters/test_claude_adapter.py @@ -78,7 +78,6 @@ def claude_payload_fixture(tmp_path) -> dict[str, Any]: "settings": { "system_prompt": "Review carefully.", "allowed_tools": ["Read"], - "disallowed_tools": ["WebFetch"], "permission_mode": "dontAsk", "max_turns": 4, "max_budget_usd": 1.5, @@ -136,7 +135,7 @@ def test_build_options_maps_normalized_capabilities_and_claude_settings(claude_p assert options.system_prompt == "Review carefully." assert options.tools is None assert options.allowed_tools == ["Read"] - assert options.disallowed_tools == ["Bash", "WebFetch"] + assert options.disallowed_tools == ["Bash"] assert options.permission_mode == "dontAsk" assert options.max_turns == 4 assert options.max_budget_usd == 1.5 @@ -327,6 +326,7 @@ def test_build_options_maps_blocked_tools_to_disallowed_tools(claude_payload): ("model_name", "FabricConfig.models"), ("cwd", "FabricConfig.environment.workspace"), ("tools", "FabricConfig.tools"), + ("disallowed_tools", "FabricConfig.tools.blocked"), ("mcp_servers", "FabricConfig.mcp"), ("skills", "FabricConfig.skills"), ],