From 20884c8de024ee6f3858e7a149492f1fd0b5dbb4 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 15 Jul 2026 10:14:19 -0600 Subject: [PATCH 1/4] feat(hermes): add Relay 0.6 dual launch modes Signed-off-by: Bryan Bednarski --- .gitignore | 1 + .../common/relay_gateway.py | 8 +- .../src/nemo_fabric_adapters/common/utils.py | 425 +++++++++++++----- adapters/deepagents/pyproject.toml | 2 +- adapters/hermes/README.md | 19 +- .../nemo_fabric_adapters/hermes/adapter.py | 406 ++++++++++++++++- crates/fabric-core/src/config.rs | 235 ++++++++-- crates/fabric-core/src/error.rs | 6 + crates/fabric-core/src/runtime.rs | 1 + .../api/python-library-reference/index.md | 4 +- .../nemo_fabric.models.md | 136 +++++- examples/code_review_agent/config.py | 23 +- .../fabric/configs/hermes-relay.yaml | 21 +- pyproject.toml | 2 +- python/src/nemo_fabric/__init__.py | 8 +- python/src/nemo_fabric/models.py | 88 +++- schemas/adapter-invocation.schema.json | 282 +++++++++--- schemas/agent.schema.json | 275 +++++++++--- schemas/effective-config.schema.json | 275 +++++++++--- schemas/run-plan.schema.json | 282 +++++++++--- tests/adapters/test_adapaters_common_utils.py | 350 +++++++++++++-- .../test_adapters_common_relay_gateway.py | 18 +- tests/adapters/test_hermes_adapter.py | 63 ++- tests/adapters/test_hermes_dual_mode.py | 369 +++++++++++++++ tests/e2e/test_hermes_e2e.py | 55 ++- .../profiles/mcp-github.yaml | 8 +- .../profiles/relay-openinference.yaml | 8 +- .../file-config-agent/profiles/relay.yaml | 8 +- tests/python/test_sdk_contract.py | 82 +++- 29 files changed, 2919 insertions(+), 541 deletions(-) create mode 100644 tests/adapters/test_hermes_dual_mode.py diff --git a/.gitignore b/.gitignore index 5a64cf21d..86cd80409 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /.env /.env.* !.env.example +/local-overlays/ __pycache__/ *.py[cod] diff --git a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py index b4360031d..7ffed699e 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -60,7 +60,7 @@ def find_available_tcp_port(host: str = "127.0.0.1") -> int: def relay_cli_observability_version(executable: Path) -> int: - """Return the observability config version accepted by a Relay CLI.""" + """Require a Relay CLI compatible with Fabric's observability v2 contract.""" try: completed = subprocess.run( @@ -79,7 +79,11 @@ def relay_cli_observability_version(executable: Path) -> int: if completed.returncode != 0 or match is None: raise RelayGatewayError("NeMo Relay CLI version could not be determined") major, minor, _ = (int(value) for value in match.groups()) - return 2 if (major, minor) >= (0, 6) else 1 + if (major, minor) < (0, 6): + raise RelayGatewayError( + "NeMo Relay 0.6 or newer is required; observability version 1 is unsupported" + ) + return 2 def wait_for_relay_gateway( diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index f815bfe53..30d1d1db0 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -8,7 +8,9 @@ import copy import json import os +import subprocess import sys +import tomllib from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -17,6 +19,8 @@ from nemo_relay import plugin from nemo_relay.observability import AtifConfig from nemo_relay.observability import AtofConfig + from nemo_relay.observability import AtofFileSinkConfig + from nemo_relay.observability import AtofStreamSinkConfig from nemo_relay.observability import HttpStorageConfig from nemo_relay.observability import OtlpConfig from nemo_relay.observability import S3StorageConfig @@ -63,11 +67,19 @@ def fabric_config(payload: dict[str, Any]) -> dict[str, Any]: def config_root(payload: dict[str, Any]) -> str: - return effective_config(payload).get("config_root") or payload.get("config_root") or "." + return ( + effective_config(payload).get("config_root") + or payload.get("config_root") + or "." + ) def agent_name(payload: dict[str, Any]) -> str: - return effective_config(payload).get("agent_name") or payload.get("agent_name") or "fabric-agent" + return ( + effective_config(payload).get("agent_name") + or payload.get("agent_name") + or "fabric-agent" + ) def load_payload() -> dict[str, Any]: @@ -99,7 +111,9 @@ def runtime_state_directory(base: str | Path, payload: dict[str, Any]) -> Path: def environment_payload(payload: dict[str, Any]) -> dict[str, Any]: - return runtime_context(payload).get("environment") or payload.get("environment") or {} + return ( + runtime_context(payload).get("environment") or payload.get("environment") or {} + ) def settings_payload(payload: dict[str, Any]) -> dict[str, Any]: @@ -135,7 +149,9 @@ def selected_model_config(payload: dict[str, Any]) -> dict[str, Any]: def telemetry_payload(payload: dict[str, Any]) -> dict[str, Any]: - telemetry = fabric_config(payload).get("telemetry") or payload.get("telemetry") or {} + telemetry = ( + fabric_config(payload).get("telemetry") or payload.get("telemetry") or {} + ) return telemetry if isinstance(telemetry, dict) else {} @@ -223,7 +239,7 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: { "kind": "observability", "enabled": True, - "config": plugin_config or {"version": 1}, + "config": plugin_config or {"version": 2}, } ], } @@ -233,36 +249,88 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: return plugin_config -def normalize_relay_output_dirs(plugin_config: dict[str, Any], payload: dict[str, Any]) -> None: +def load_relay_dynamic_plugins(payload: dict[str, Any]) -> list[dict[str, Any]]: + """Load ordered dynamic plugin specs and resolve invocation-relative paths.""" + + config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") + if not config_path: + raise RuntimeError("FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled") + + with Path(config_path).open(encoding="utf-8") as stream: + wrapper = json.load(stream) + + specs = (wrapper.get("relay") or {}).get("dynamic_plugins") or [] + if not isinstance(specs, list): + raise ValueError("relay.dynamic_plugins must be a list") + root = Path(config_root(payload)).resolve() + resolved: list[dict[str, Any]] = [] + for index, spec in enumerate(specs): + if not isinstance(spec, dict): + raise ValueError(f"relay.dynamic_plugins[{index}] must be an object") + item = copy.deepcopy(spec) + for field in ("manifest_ref", "environment_ref"): + value = item.get(field) + if value is None: + continue + path = Path(str(value)) + item[field] = str(path if path.is_absolute() else (root / path).resolve()) + resolved.append(item) + return resolved + + +def normalize_relay_output_dirs( + plugin_config: dict[str, Any], payload: dict[str, Any] +) -> None: base = Path(config_root(payload)).resolve() runtime_id = runtime_context(payload)["runtime_id"] for component in plugin_config.get("components", []): if component.get("kind") != "observability": continue config = component.setdefault("config", {}) - config.setdefault("version", 1) - for section_name in ("atof", "atif"): - section = config.get(section_name) - if not isinstance(section, dict) or not section.get("enabled"): - continue + version = int(config.setdefault("version", 2)) + if version != 2: + raise ValueError("NeMo Relay observability config version 2 is required") - output_directory = section.get("output_directory") - if output_directory: - path = Path(output_directory) + atof = config.get("atof") + if isinstance(atof, dict) and atof.get("enabled"): + sinks = atof.setdefault("sinks", []) + if not isinstance(sinks, list): + raise ValueError("Relay ATOF sinks must be a list") + if not any( + isinstance(sink, dict) and sink.get("type") == "file" for sink in sinks + ): + sinks.append({"type": "file"}) + for sink in sinks: + if not isinstance(sink, dict) or sink.get("type") != "file": + continue + output_directory = sink.get("output_directory") + path = ( + Path(output_directory) + if output_directory + else base / "artifacts" / "relay" + ) if not path.is_absolute(): path = base / path - else: - path = base / "artifacts" / "relay" - - section["output_directory"] = str(path / str(runtime_id)) - Path(section["output_directory"]).mkdir(parents=True, exist_ok=True) - if section_name == "atof": - section.setdefault("filename", "events.atof.jsonl") - section.setdefault("mode", "overwrite") - if section_name == "atif": - section.setdefault("filename_template", "trajectory-{session_id}.atif.json") - section.setdefault("agent_name", agent_name(payload)) - section.setdefault("model_name", relay_model_name(payload)) + sink["output_directory"] = str(path / str(runtime_id)) + Path(sink["output_directory"]).mkdir(parents=True, exist_ok=True) + sink.setdefault("filename", "events.atof.jsonl") + sink.setdefault("mode", "overwrite") + + atif = config.get("atif") + if isinstance(atif, dict) and atif.get("enabled"): + output_directory = atif.get("output_directory") + path = ( + Path(output_directory) + if output_directory + else base / "artifacts" / "relay" + ) + if not path.is_absolute(): + path = base / path + atif["output_directory"] = str(path / str(runtime_id)) + Path(atif["output_directory"]).mkdir(parents=True, exist_ok=True) + atif.setdefault("filename_template", "trajectory-{session_id}.atif.json") + atif.setdefault("agent_name", agent_name(payload)) + atif.setdefault("model_name", relay_model_name(payload)) def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfig: @@ -278,17 +346,23 @@ def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfi enabled = bool(component.get("enabled", True)) config = component.get("config") or {} if component.get("kind") == "observability" and isinstance(config, dict): - policy = config.get("policy") if isinstance(config.get("policy"), dict) else {} + policy = ( + config.get("policy") if isinstance(config.get("policy"), dict) else {} + ) components.append( ComponentSpec( ObservabilityConfig( - version=int(config.get("version", 1)), + version=int(config.get("version", 2)), atof=_relay_api_atof_config(config.get("atof")), atif=_relay_api_atif_config( config.get("atif"), ), - opentelemetry=_relay_api_otlp_config(config.get("opentelemetry")), - openinference=_relay_api_otlp_config(config.get("openinference")), + opentelemetry=_relay_api_otlp_config( + config.get("opentelemetry") + ), + openinference=_relay_api_otlp_config( + config.get("openinference") + ), policy=ConfigPolicy( unknown_component=policy.get("unknown_component", "warn"), unknown_field=policy.get("unknown_field", "warn"), @@ -307,7 +381,11 @@ def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfi ) ) - policy = plugin_config.get("policy") if isinstance(plugin_config.get("policy"), dict) else {} + policy = ( + plugin_config.get("policy") + if isinstance(plugin_config.get("policy"), dict) + else {} + ) plugin_config = plugin.PluginConfig( version=int(plugin_config.get("version", 1)), components=components, @@ -325,31 +403,63 @@ def relay_api_plugin_config(plugin_config: dict[str, Any]) -> plugin.PluginConfi return plugin_config +def relay_api_dynamic_plugins( + specs: list[dict[str, Any]], +) -> list[plugin.DynamicPluginActivationSpec]: + """Convert Fabric's typed dynamic plugin specs to Relay's owned host API.""" + + from nemo_relay import plugin + + return [ + plugin.DynamicPluginActivationSpec( + plugin_id=str(spec["plugin_id"]), + kind=spec["kind"], + manifest_ref=str(spec["manifest_ref"]), + environment_ref=( + str(spec["environment_ref"]) + if spec.get("environment_ref") is not None + else None + ), + config=spec.get("config") or {}, + ) + for spec in specs + ] + + def _relay_api_atof_config(value: Any) -> AtofConfig | None: if not isinstance(value, dict): return None from nemo_relay.observability import AtofConfig - from nemo_relay.observability import AtofEndpointConfig - - endpoint_configs = value.get("endpoints") - endpoints = None - if isinstance(endpoint_configs, list): - endpoints = [ - AtofEndpointConfig( - url=str(endpoint.get("url", "")), - transport=endpoint.get("transport", "http_post"), - headers=endpoint.get("headers", {}), - timeout_millis=int(endpoint.get("timeout_millis", 3000)), + from nemo_relay.observability import AtofFileSinkConfig + from nemo_relay.observability import AtofStreamSinkConfig + + sinks: list[AtofFileSinkConfig | AtofStreamSinkConfig] = [] + for sink in value.get("sinks") or []: + if not isinstance(sink, dict): + continue + if sink.get("type") == "file": + sinks.append( + AtofFileSinkConfig( + output_directory=sink.get("output_directory"), + filename=sink.get("filename"), + mode=sink.get("mode", "append"), + ) + ) + elif sink.get("type") == "stream": + sinks.append( + AtofStreamSinkConfig( + name=sink.get("name"), + url=str(sink.get("url", "")), + transport=sink.get("transport", "http_post"), + headers=sink.get("headers", {}), + header_env=sink.get("header_env", {}), + timeout_millis=int(sink.get("timeout_millis", 3000)), + field_name_policy=sink.get("field_name_policy", "preserve"), + ) ) - for endpoint in endpoint_configs - if isinstance(endpoint, dict) - ] return AtofConfig( enabled=bool(value.get("enabled", False)), - output_directory=value.get("output_directory"), - filename=value.get("filename"), - mode=value.get("mode", "append"), - endpoints=endpoints, + sinks=sinks, ) @@ -361,7 +471,11 @@ 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"), @@ -370,12 +484,16 @@ def _relay_api_atif_config(value: Any) -> AtifConfig | None: tool_definitions=value.get("tool_definitions"), extra=value.get("extra"), output_directory=value.get("output_directory"), - filename_template=value.get("filename_template", "nemo-relay-atif-{session_id}.json"), + filename_template=value.get( + "filename_template", "nemo-relay-atif-{session_id}.json" + ), storage=storage, ) -def _relay_api_storage_config(value: dict[str, Any]) -> HttpStorageConfig | S3StorageConfig: +def _relay_api_storage_config( + value: dict[str, Any], +) -> HttpStorageConfig | S3StorageConfig: if value.get("type") == "s3": from nemo_relay.observability import S3StorageConfig @@ -406,6 +524,8 @@ def _relay_api_otlp_config(value: Any) -> OtlpConfig | None: return OtlpConfig( enabled=bool(value.get("enabled", False)), + mark_projection=value.get("mark_projection", "inherit"), + mark_exclude_names=value.get("mark_exclude_names", ["llm.chunk"]), transport=value.get("transport", "http_binary"), endpoint=value.get("endpoint"), headers=value.get("headers", {}), @@ -415,6 +535,7 @@ def _relay_api_otlp_config(value: Any) -> OtlpConfig | None: service_version=value.get("service_version"), instrumentation_scope=value.get("instrumentation_scope"), timeout_millis=int(value.get("timeout_millis", 3000)), + attribute_mappings=value.get("attribute_mappings", []), ) @@ -424,18 +545,29 @@ def collect_relay_artifacts(plugin_config: dict[str, Any]) -> list[dict[str, str if component.get("kind") != "observability": continue config = component.get("config") or {} - for section_name, pattern in ( - ("atof", "*.jsonl"), - ("atif", "*.json"), - ): - section = config.get(section_name) - if not isinstance(section, dict) or not section.get("enabled"): - continue - directory = Path(section.get("output_directory") or ".") - if not directory.exists(): - continue - for path in sorted(directory.glob(pattern)): - artifacts.append({"kind": section_name, "path": str(path)}) + atof = config.get("atof") + if isinstance(atof, dict) and atof.get("enabled"): + for sink in atof.get("sinks") or []: + if not isinstance(sink, dict) or sink.get("type") != "file": + continue + directory = Path(sink.get("output_directory") or ".") + if directory.exists(): + filename = sink.get("filename") + paths = ( + [directory / str(filename)] + if filename + else sorted(directory.glob("*.jsonl")) + ) + for path in paths: + if not path.is_file(): + continue + artifacts.append({"kind": "atof", "path": str(path)}) + atif = config.get("atif") + if isinstance(atif, dict) and atif.get("enabled"): + directory = Path(atif.get("output_directory") or ".") + if directory.exists(): + for path in sorted(directory.glob("*.json")): + artifacts.append({"kind": "atif", "path": str(path)}) return artifacts @@ -445,55 +577,16 @@ def relay_cli_plugin_config( """Render normalized Relay intent for the current external CLI contract.""" rendered = copy.deepcopy(plugin_config) - if observability_version == 1: - return rendered if observability_version != 2: raise ValueError( - f"unsupported NeMo Relay observability config version {observability_version}" + "NeMo Relay 0.6 or newer is required; observability version 1 is unsupported" ) for component in rendered.get("components", []): if not isinstance(component, dict) or component.get("kind") != "observability": continue config = component.get("config") - if not isinstance(config, dict) or int(config.get("version", 1)) != 1: - continue - - atof = config.get("atof") - if isinstance(atof, dict): - sinks = list(atof.get("sinks") or []) - if atof.get("enabled"): - file_sink = without_none( - { - "type": "file", - "output_directory": atof.get("output_directory"), - "filename": atof.get("filename"), - "mode": atof.get("mode", "append"), - } - ) - sinks.append(file_sink) - for endpoint in atof.get("endpoints") or []: - if not isinstance(endpoint, dict): - continue - sinks.append( - without_none( - { - "type": "stream", - "url": endpoint.get("url"), - "transport": endpoint.get("transport", "http_post"), - "headers": endpoint.get("headers", {}), - "header_env": endpoint.get("header_env", {}), - "timeout_millis": endpoint.get("timeout_millis", 3000), - "field_name_policy": endpoint.get( - "field_name_policy", "preserve" - ), - } - ) - ) - config["atof"] = { - "enabled": bool(atof.get("enabled", False)), - "sinks": sinks, - } - config["version"] = 2 + if isinstance(config, dict) and int(config.get("version", 2)) != 2: + raise ValueError("NeMo Relay observability config version 2 is required") return rendered @@ -501,14 +594,16 @@ def write_relay_configs( *, relay_config: dict[str, Any] | None = None, plugin_config: dict[str, Any] | None = None, - observability_version: int = 1, + observability_version: int = 2, ) -> tuple[Path | None, Path | None]: try: import tomli_w config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") if not config_path: - raise RuntimeError("FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled") + raise RuntimeError( + "FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled" + ) config_path = Path(config_path) config_dir = config_path.parent / "relay-config" @@ -537,6 +632,120 @@ def write_relay_configs( raise RuntimeError("tomli_w is not installed") from e +def provision_relay_dynamic_plugins( + *, + executable: Path, + relay_config_path: Path, + plugin_config_path: Path, + specs: list[dict[str, Any]], + env: dict[str, str], + cwd: Path, +) -> list[dict[str, Any]]: + """Provision dynamic plugins through Relay's invocation-scoped CLI lifecycle.""" + + receipts: list[dict[str, Any]] = [] + for spec in specs: + plugin_id = str(spec["plugin_id"]) + manifest_ref = str(spec["manifest_ref"]) + if not _relay_plugin_manifest_registered(plugin_config_path, manifest_ref): + _run_relay_lifecycle_command( + executable, + relay_config_path, + ["plugins", "add", manifest_ref], + env=env, + cwd=cwd, + ) + _attach_relay_dynamic_plugin_config( + plugin_config_path, + manifest_ref, + spec.get("config") or {}, + ) + _run_relay_lifecycle_command( + executable, + relay_config_path, + ["plugins", "enable", plugin_id], + env=env, + cwd=cwd, + ) + _run_relay_lifecycle_command( + executable, + relay_config_path, + ["plugins", "validate", plugin_id, "--json"], + env=env, + cwd=cwd, + ) + receipts.append( + { + "plugin_id": plugin_id, + "kind": str(spec["kind"]), + "registered": True, + "enabled": True, + "validated": True, + } + ) + return receipts + + +def _run_relay_lifecycle_command( + executable: Path, + relay_config_path: Path, + args: list[str], + *, + env: dict[str, str], + cwd: Path, +) -> None: + completed = subprocess.run( + [str(executable), "--config", str(relay_config_path), *args], + cwd=cwd, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if completed.returncode != 0: + action = " ".join(args[:2]) + raise RuntimeError( + f"NeMo Relay {action} failed with status {completed.returncode}" + ) + + +def _relay_plugin_manifest_registered( + plugin_config_path: Path, manifest_ref: str +) -> bool: + if not plugin_config_path.is_file(): + return False + document = tomllib.loads(plugin_config_path.read_text(encoding="utf-8")) + entries = (document.get("plugins") or {}).get("dynamic") or [] + target = Path(manifest_ref).resolve() + return any( + isinstance(entry, dict) + and Path(str(entry.get("manifest", ""))).resolve() == target + for entry in entries + ) + + +def _attach_relay_dynamic_plugin_config( + plugin_config_path: Path, + manifest_ref: str, + config: dict[str, Any], +) -> None: + import tomli_w + + document = tomllib.loads(plugin_config_path.read_text(encoding="utf-8")) + entries = (document.get("plugins") or {}).get("dynamic") or [] + target = Path(manifest_ref).resolve() + for entry in entries: + if not isinstance(entry, dict): + continue + if Path(str(entry.get("manifest", ""))).resolve() == target: + entry["config"] = copy.deepcopy(config) + plugin_config_path.write_text(tomli_w.dumps(document), encoding="utf-8") + return + raise RuntimeError( + "Relay lifecycle did not register the requested dynamic plugin manifest" + ) + def relay_model_name(payload: dict[str, Any]) -> str: settings = settings_payload(payload) diff --git a/adapters/deepagents/pyproject.toml b/adapters/deepagents/pyproject.toml index 676b8ba9b..7488cbddc 100644 --- a/adapters/deepagents/pyproject.toml +++ b/adapters/deepagents/pyproject.toml @@ -34,7 +34,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.5.0", + "nemo-relay~=0.6.0", ] [project.urls] diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index d9b62c2ac..7ee8aa19a 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -5,7 +5,21 @@ SPDX-License-Identifier: Apache-2.0 # Hermes Agent Adapter -This adapter runs Hermes Agent through its Python SDK. +This adapter runs Hermes Agent through one of two Relay-compatible execution +strategies selected by `harness.settings.relay_launch_mode`: + +- `native_plugin` (default) invokes the Hermes Python SDK and activates Relay + through its Python API. When `relay.dynamic_plugins` is non-empty, the + adapter retains Relay's owned dynamic-plugin host for the complete Hermes + call. +- `cli_wrapper` invokes `nemo-relay run --agent hermes`. Relay owns the + transient gateway, hook injection, child process, and cleanup. Dynamic + plugins are provisioned through Relay's lifecycle in invocation-isolated + directories before Hermes starts. + +`cli_wrapper` requires Relay telemetry and Relay 0.6 or newer. Override the +executable with `harness.settings.relay_cli_command`; it defaults to +`nemo-relay`. Fabric invokes the adapter module with `python -m` through the core runtime lifecycle. The module entry point and the descriptor's callable route use the @@ -22,7 +36,8 @@ configuration for: - 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. +- optional NeMo Relay 0.6 observability, built-in component, and dynamic-plugin + configuration. `hermes_home` configures a base directory. The adapter creates a child under `runtimes/` so invocations in one Fabric runtime share Hermes state diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index b7fc9f403..604d820a1 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -11,15 +11,21 @@ from __future__ import annotations import asyncio +import copy import inspect import json import os +import shutil +import subprocess import sys -from contextlib import redirect_stdout +from collections.abc import Iterator +from contextlib import contextmanager, redirect_stdout +from dataclasses import dataclass from io import StringIO from pathlib import Path from typing import Any +import nemo_fabric_adapters.common.relay_gateway as relay_gateway import nemo_fabric_adapters.common.utils as common_utils # Default agent loop budget when harness.settings.max_iterations is unset. @@ -27,6 +33,20 @@ # as 1 silently starves multi-step tasks (they run out of budget before # answering while the trial still reports success). See FABRIC-85. DEFAULT_MAX_ITERATIONS: int = 90 +NATIVE_PLUGIN_MODE = "native_plugin" +CLI_WRAPPER_MODE = "cli_wrapper" +RELAY_LAUNCH_MODES = {NATIVE_PLUGIN_MODE, CLI_WRAPPER_MODE} + + +@dataclass(frozen=True) +class RelayCliLaunch: + """Invocation-scoped inputs for Relay's transparent Hermes runner.""" + + executable: Path + config_path: Path + plugin_config_path: Path + env: dict[str, str] + activation_receipt: list[dict[str, Any]] def validate_hermes_telemetry_provider(payload: dict[str, Any]) -> None: @@ -43,7 +63,9 @@ def disabled_toolsets(payload: dict[str, Any]) -> list[str]: ) -def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) -> dict[str, Any]: +def build_hermes_config( + payload: dict[str, Any], *, relay_enabled: bool = False +) -> dict[str, Any]: settings = common_utils.settings_payload(payload) model_config = common_utils.selected_model_config(payload) native = common_utils.capability_plan(payload).get("native") or {} @@ -71,7 +93,9 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) "terminal": common_utils.without_none( { "backend": settings.get("terminal_backend", "local"), - "cwd": str(environment.get("workspace") or settings.get("workspace") or "."), + "cwd": str( + environment.get("workspace") or settings.get("workspace") or "." + ), "timeout": settings.get("terminal_timeout", 60), } ), @@ -90,7 +114,9 @@ def build_hermes_config(payload: dict[str, Any], *, relay_enabled: bool = False) if "enabled_toolsets" in settings: config["platform_toolsets"] = { - settings.get("toolset_platform", "cli"): common_utils.normalize_list(settings.get("enabled_toolsets")) + settings.get("toolset_platform", "cli"): common_utils.normalize_list( + settings.get("enabled_toolsets") + ) } plugins = common_utils.normalize_list(settings.get("plugins_enabled")) @@ -151,7 +177,9 @@ def run(payload: dict[str, Any]) -> dict[str, Any]: return asyncio.run(run_hermes(payload)) -def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) -> list[str] | None: +def resolve_hermes_toolsets( + settings: dict[str, Any], config: dict[str, Any] +) -> list[str] | None: if "enabled_toolsets" in settings: return common_utils.normalize_list(settings.get("enabled_toolsets")) @@ -161,7 +189,9 @@ def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) -> return sorted(_get_platform_tools(config, platform)) -def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[str, Any]] | None: +def load_runtime_history( + session_db: Any, session_id: str | None +) -> list[dict[str, Any]] | None: if not session_id: return None @@ -173,10 +203,268 @@ def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[s return None messages = session_db.get_messages_as_conversation(resolved_id) - messages = [message for message in messages if message.get("role") != "session_meta"] + messages = [ + message for message in messages if message.get("role") != "session_meta" + ] return messages or None +def ensure_hermes_runtime_session( + runtime_id: str, + model_name: str, + model_config: dict[str, Any], + hermes_home: Path, +) -> None: + """Create the Hermes session that CLI continuation maps to, if necessary.""" + + from hermes_state import SessionDB + + session_db = SessionDB(db_path=hermes_home / "state.db") + if session_db.get_session_by_title(runtime_id) is None: + session_db.ensure_session( + runtime_id, + source="fabric", + model=model_name, + model_config=model_config, + ) + session_db.set_session_title(session_id=runtime_id, title=runtime_id) + + +def _relay_launch_mode(settings: dict[str, Any]) -> str: + mode = str(settings.get("relay_launch_mode", NATIVE_PLUGIN_MODE)).strip() + if mode not in RELAY_LAUNCH_MODES: + supported = ", ".join(sorted(RELAY_LAUNCH_MODES)) + raise ValueError( + f"unsupported relay_launch_mode={mode!r}; expected one of: {supported}" + ) + return mode + + +def _resolve_executable(config_root: Path, value: Any, *, label: str) -> Path: + command = Path(str(value)) + if command.is_absolute() or len(command.parts) > 1: + candidate = command if command.is_absolute() else config_root / command + resolved = candidate.resolve() + if resolved.is_file() and os.access(resolved, os.X_OK): + return resolved + else: + found = shutil.which(str(command)) + if found: + return Path(found).resolve() + raise RuntimeError(f"{label} executable was not found: {value}") + + +def _resolve_path(config_root: Path, value: Any) -> Path: + path = Path(str(value)) + return path if path.is_absolute() else config_root / path + + +def prepare_relay_cli_launch( + *, + payload: dict[str, Any], + settings: dict[str, Any], + model_config: dict[str, Any], + hermes_home: Path, + hermes_config_path: Path, + plugin_config: dict[str, Any], + dynamic_plugins: list[dict[str, Any]], +) -> RelayCliLaunch: + """Write Relay inputs and provision dynamic plugins for one invocation.""" + + config_root = Path(common_utils.config_root(payload)).resolve() + relay_executable = relay_gateway.resolve_relay_command( + config_root, + settings.get("relay_cli_command", "nemo-relay"), + ) + relay_gateway.relay_cli_observability_version(relay_executable) + hermes_executable = _resolve_executable( + config_root, + settings.get("hermes_command", "hermes"), + label="Hermes", + ) + base_url = common_utils.get_base_url(settings, model_config) + relay_config: dict[str, Any] = { + "agents": { + "hermes": { + "command": str(hermes_executable), + "hooks_path": str(hermes_config_path), + } + } + } + if base_url: + relay_config["upstream"] = {"openai_base_url": base_url} + + relay_config_path, plugin_config_path = common_utils.write_relay_configs( + relay_config=relay_config, + plugin_config=plugin_config, + observability_version=2, + ) + if relay_config_path is None or plugin_config_path is None: + raise RuntimeError("Relay CLI wrapper configuration was not written") + + env = common_utils.virtualenv_subprocess_env() + env.update( + {str(key): str(value) for key, value in (settings.get("env") or {}).items()} + ) + invocation_id = str( + common_utils.runtime_context(payload).get("invocation_id") + or common_utils.runtime_id(payload) + ) + isolation_root = hermes_home / "relay-cli" / invocation_id + for name, leaf in ( + ("XDG_CONFIG_HOME", "config"), + ("XDG_STATE_HOME", "state"), + ("XDG_CACHE_HOME", "cache"), + ("XDG_DATA_HOME", "data"), + ): + path = isolation_root / leaf + path.mkdir(parents=True, exist_ok=True) + env[name] = str(path) + + activation_receipt = common_utils.provision_relay_dynamic_plugins( + executable=relay_executable, + relay_config_path=relay_config_path, + plugin_config_path=plugin_config_path, + specs=dynamic_plugins, + env=env, + cwd=config_root, + ) + return RelayCliLaunch( + executable=relay_executable, + config_path=relay_config_path, + plugin_config_path=plugin_config_path, + env=env, + activation_receipt=activation_receipt, + ) + + +def write_native_relay_plugin_config( + plugin_config: dict[str, Any], dynamic_plugins: list[dict[str, Any]] +) -> Path: + """Expose Relay's layered native config to Hermes's managed middleware.""" + + merged = copy.deepcopy(plugin_config) + components = merged.setdefault("components", []) + for spec in dynamic_plugins: + components.append( + { + "kind": spec["plugin_id"], + "enabled": True, + "config": copy.deepcopy(spec.get("config") or {}), + } + ) + _, plugin_config_path = common_utils.write_relay_configs( + plugin_config=merged, + observability_version=2, + ) + if plugin_config_path is None: + raise RuntimeError("Relay native plugin configuration was not written") + return plugin_config_path + + +@contextmanager +def native_relay_plugin_environment(plugin_config_path: Path) -> Iterator[None]: + """Point Hermes at the active Relay config without leaking across runs.""" + + name = "HERMES_NEMO_RELAY_PLUGINS_TOML" + previous = os.environ.get(name) + os.environ[name] = str(plugin_config_path) + try: + yield + finally: + if previous is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous + + +def build_relay_hermes_command( + *, + launch: RelayCliLaunch, + payload: dict[str, Any], + settings: dict[str, Any], + model_config: dict[str, Any], + user_message: str, +) -> list[str]: + """Build the transparent Relay command while preserving Hermes invocation state.""" + + hermes_args = ["chat", "--quiet", "--query", user_message] + hermes_args.extend(["--continue", common_utils.runtime_id(payload)]) + model_name = settings.get("model_name") or model_config.get("model") + if model_name: + hermes_args.extend(["--model", str(model_name)]) + hermes_args.extend(["--provider", "custom"]) + toolsets = common_utils.normalize_list(settings.get("enabled_toolsets")) + if toolsets: + hermes_args.extend(["--toolsets", ",".join(toolsets)]) + return [ + str(launch.executable), + "run", + "--config", + str(launch.config_path), + "--agent", + "hermes", + "--plugin-config-path", + str(launch.plugin_config_path), + "--", + *hermes_args, + ] + + +def invoke_relay_wrapped_hermes( + *, + command: list[str], + cwd: Path, + env: dict[str, str], +) -> tuple[dict[str, Any], list[str] | None, str]: + """Run Relay's owned Hermes lifecycle and adapt the result to Fabric output.""" + + completed = subprocess.run( + command, + cwd=cwd.resolve(), + env=env, + text=True, + capture_output=True, + check=False, + ) + response = completed.stdout.strip() + error = ( + None + if completed.returncode == 0 + else completed.stderr.strip() + or (f"nemo-relay run exited with status {completed.returncode}") + ) + result = { + "response": response, + "final_response": response, + "completed": completed.returncode == 0, + "failed": completed.returncode != 0, + "api_calls": None, + "messages": [], + "error": error, + "returncode": completed.returncode, + } + return result, None, completed.stderr + + +def redact_command(command: list[str]) -> list[str]: + """Redact user input and any accidentally embedded secret-shaped arguments.""" + + redacted: list[str] = [] + redact_next = False + for arg in command: + if redact_next: + redacted.append("") + redact_next = False + elif any(marker in arg.upper() for marker in ("API_KEY", "TOKEN", "SECRET")): + redacted.append("") + else: + redacted.append(arg) + if arg == "--query": + redact_next = True + return redacted + + async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: validate_hermes_telemetry_provider(payload) settings = common_utils.settings_payload(payload) @@ -195,18 +483,29 @@ async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: os.environ.setdefault("TERMINAL_ENV", settings.get("terminal_backend", "local")) os.environ.setdefault("TERMINAL_TIMEOUT", str(settings.get("terminal_timeout", 60))) relay_enabled = common_utils.relay_enabled(payload) + relay_launch_mode = _relay_launch_mode(settings) + if relay_launch_mode == CLI_WRAPPER_MODE and not relay_enabled: + raise RuntimeError( + "relay_launch_mode=cli_wrapper requires Relay telemetry to be enabled" + ) relay_plugin_config = None + relay_dynamic_plugins: list[dict[str, Any]] = [] if relay_enabled: relay_plugin_config = common_utils.load_relay_plugin_config(payload) + relay_dynamic_plugins = common_utils.load_relay_dynamic_plugins(payload) hermes_config_path, hermes_config = write_hermes_config( payload, hermes_home, - relay_enabled=relay_enabled, + relay_enabled=relay_enabled and relay_launch_mode == NATIVE_PLUGIN_MODE, ) - api_key_env = settings.get("api_key_env") or model_config.get("api_key_env") or "NVIDIA_API_KEY" + api_key_env = ( + settings.get("api_key_env") + or model_config.get("api_key_env") + or "NVIDIA_API_KEY" + ) api_key = os.environ.get(api_key_env) if not api_key: raise RuntimeError(f"{api_key_env} is required for Hermes mode") @@ -226,14 +525,70 @@ async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: "relay_plugin_config": relay_plugin_config, } - if relay_enabled: - relay_api_config = common_utils.relay_api_plugin_config(relay_plugin_config or {}) + activation_report = None + cli_launch = None + command: list[str] | None = None + if relay_launch_mode == CLI_WRAPPER_MODE: + ensure_hermes_runtime_session( + common_utils.runtime_id(payload), + settings.get("model_name") or model_config.get("model", ""), + model_config, + hermes_home, + ) + cli_launch = prepare_relay_cli_launch( + payload=payload, + settings=settings, + model_config=model_config, + hermes_home=hermes_home, + hermes_config_path=hermes_config_path, + plugin_config=relay_plugin_config or {}, + dynamic_plugins=relay_dynamic_plugins, + ) + command = build_relay_hermes_command( + launch=cli_launch, + payload=payload, + settings=settings, + model_config=model_config, + user_message=user_message, + ) + result, enabled_toolsets, adapter_stdout = invoke_relay_wrapped_hermes( + command=command, + cwd=_resolve_path( + Path(common_utils.config_root(payload)).resolve(), + common_utils.environment_payload(payload).get("workspace") or ".", + ), + env=cli_launch.env, + ) + relay_artifacts: list[dict[str, str]] = [] + elif relay_enabled: + relay_api_config = common_utils.relay_api_plugin_config( + relay_plugin_config or {} + ) + native_plugin_config_path = write_native_relay_plugin_config( + relay_plugin_config or {}, relay_dynamic_plugins + ) from nemo_relay import plugin - async with plugin.plugin(relay_api_config): - (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) + with native_relay_plugin_environment(native_plugin_config_path): + if relay_dynamic_plugins: + activation = await plugin.initialize_with_dynamic_plugins( + relay_api_config, + common_utils.relay_api_dynamic_plugins(relay_dynamic_plugins), + ) + activation_report = activation.report + async with activation: + (result, enabled_toolsets, relay_artifacts, adapter_stdout) = ( + _invoke_hermes(**hermes_kwargs) + ) + else: + async with plugin.plugin(relay_api_config): + (result, enabled_toolsets, relay_artifacts, adapter_stdout) = ( + _invoke_hermes(**hermes_kwargs) + ) else: - (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes(**hermes_kwargs) + (result, enabled_toolsets, relay_artifacts, adapter_stdout) = _invoke_hermes( + **hermes_kwargs + ) if relay_plugin_config is not None: relay_artifacts = common_utils.collect_relay_artifacts(relay_plugin_config) @@ -242,7 +597,7 @@ async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: messages = result.get("messages") or [] output = { "harness": "hermes", - "adapter": "python", + "adapter": "cli" if relay_launch_mode == CLI_WRAPPER_MODE else "python", "mode": "hermes", "model": model_config.get("model"), "base_url": base_url, @@ -258,13 +613,25 @@ async def run_hermes(payload: dict[str, Any]) -> dict[str, Any]: "hermes_config_path": str(hermes_config_path), "hermes_native_config": summarize_hermes_config(hermes_config), "enabled_toolsets": enabled_toolsets, + "relay_launch_mode": relay_launch_mode, } + if command is not None: + output["command"] = redact_command(command) + output["returncode"] = result.get("returncode") if relay_plugin_config is not None: output["relay_runtime"] = { "enabled": True, "config_path": os.environ.get("FABRIC_RELAY_CONFIG_PATH"), - "emitter": "hermes.observability/nemo_relay", + "emitter": ( + "nemo-relay.cli-wrapper" + if relay_launch_mode == CLI_WRAPPER_MODE + else "hermes.observability/nemo_relay" + ), } + if activation_report is not None: + output["relay_runtime"]["activation_report"] = activation_report + if cli_launch is not None: + output["relay_runtime"]["dynamic_plugins"] = cli_launch.activation_receipt output["relay_artifacts"] = relay_artifacts return output @@ -315,7 +682,9 @@ def _invoke_hermes( skip_memory=True, save_trajectories=bool(settings.get("save_trajectories", False)), max_tokens=settings.get("max_tokens", 512), - temperature=settings.get("temperature", model_config.get("temperature", 0.0)), + temperature=settings.get( + "temperature", model_config.get("temperature", 0.0) + ), reasoning_config=settings.get("reasoning_config", {"effort": "none"}), insert_reasoning=bool(settings.get("insert_reasoning", False)), platform="fabric", @@ -340,7 +709,8 @@ def _invoke_hermes( invoke_hook( "on_session_finalize", session_id=getattr(agent, "session_id", ""), - model=getattr(agent, "model", None) or common_utils.relay_model_name(payload), + model=getattr(agent, "model", None) + or common_utils.relay_model_name(payload), platform=getattr(agent, "platform", None) or "fabric", ) diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index a40d3abce..def66dd3d 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -668,6 +668,9 @@ pub struct RelayConfig { /// Additional Relay plugin components. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub components: Vec, + /// Ordered manifest-backed plugins activated for this run. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dynamic_plugins: Vec, /// Relay plugin validation policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub policy: Option, @@ -676,6 +679,33 @@ pub struct RelayConfig { pub extensions: BTreeMap, } +/// One invocation-scoped NeMo Relay dynamic plugin activation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RelayDynamicPluginConfig { + /// Canonical plugin identifier declared by the manifest. + pub plugin_id: String, + /// Dynamic plugin execution lane. + pub kind: RelayDynamicPluginKind, + /// Path to the authored `relay-plugin.toml`. + pub manifest_ref: PathBuf, + /// Optional lifecycle-managed environment path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_ref: Option, + /// Component-local plugin configuration. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub config: BTreeMap, +} + +/// Relay dynamic plugin execution lane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayDynamicPluginKind { + /// In-process native Rust dynamic library. + RustDynamic, + /// Out-of-process worker plugin. + Worker, +} + /// Generic NeMo Relay plugin component configuration. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RelayComponentConfig { @@ -738,43 +768,53 @@ pub struct RelayAtofConfig { /// Whether ATOF export is enabled. #[serde(default)] pub enabled: bool, - /// 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 mode: RelayAtofMode, - /// Optional remote ATOF endpoints. + /// Ordered ATOF destinations. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub endpoints: Vec, + pub sinks: Vec, /// Additive ATOF fields. #[serde(default, flatten)] pub extensions: BTreeMap, } -/// Relay ATOF endpoint configuration. +/// Relay ATOF destination. #[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, +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RelayAtofSinkConfig { + /// Filesystem ATOF JSONL destination. + File { + /// Directory used for ATOF files. + #[serde(default, skip_serializing_if = "Option::is_none")] + output_directory: Option, + /// ATOF file name. + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + /// File write mode. + #[serde(default)] + mode: RelayAtofMode, + }, + /// Remote streaming ATOF destination. + Stream { + /// Optional stable sink name. + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + /// Endpoint URL. + url: String, + /// Endpoint transport. + #[serde(default)] + transport: RelayAtofEndpointTransport, + /// Static endpoint headers. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + headers: BTreeMap, + /// Environment-variable-backed endpoint 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, + /// Field-name handling policy. + #[serde(default)] + field_name_policy: RelayAtofEndpointFieldNamePolicy, + }, } /// Relay ATIF export configuration. @@ -889,6 +929,12 @@ pub struct RelayOtlpConfig { /// Whether OTLP export is enabled. #[serde(default)] pub enabled: bool, + /// Projection shape for Relay marks. + #[serde(default)] + pub mark_projection: RelayMarkProjection, + /// Mark names excluded from projection. + #[serde(default = "default_relay_mark_exclude_names")] + pub mark_exclude_names: Vec, /// OTLP transport. #[serde(default)] pub transport: RelayOtlpTransport, @@ -916,6 +962,9 @@ pub struct RelayOtlpConfig { /// Request timeout in milliseconds. #[serde(default = "default_relay_timeout_millis")] pub timeout_millis: u64, + /// Typed Relay-event attribute mappings. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attribute_mappings: Vec, /// Additive OTLP fields. #[serde(default, flatten)] pub extensions: BTreeMap, @@ -925,6 +974,8 @@ impl Default for RelayOtlpConfig { fn default() -> Self { Self { enabled: false, + mark_projection: RelayMarkProjection::default(), + mark_exclude_names: default_relay_mark_exclude_names(), transport: RelayOtlpTransport::default(), endpoint: None, headers: BTreeMap::new(), @@ -934,11 +985,34 @@ impl Default for RelayOtlpConfig { service_version: None, instrumentation_scope: None, timeout_millis: default_relay_timeout_millis(), + attribute_mappings: Vec::new(), extensions: BTreeMap::new(), } } } +/// Relay mark projection shape for OTLP exporters. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RelayMarkProjection { + /// Preserve each mark's requested projection. + #[default] + Inherit, + /// Export marks as span events. + Event, + /// Export marks as tool spans. + Tool, +} + +/// One Relay event-field to OTLP attribute mapping. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct RelayOtlpAttributeMapping { + /// Canonical Relay event-field key. + pub key: String, + /// Exported OTLP attribute alias. + pub alias: String, +} + /// Relay validation policy. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RelayConfigPolicy { @@ -1046,7 +1120,11 @@ impl TelemetryProvider { } fn default_relay_config_version() -> u32 { - 1 + 2 +} + +fn default_relay_mark_exclude_names() -> Vec { + vec!["llm.chunk".to_string()] } fn default_enabled() -> bool { @@ -1722,6 +1800,9 @@ fn resolve_telemetry_plan( let native_provider = telemetry.providers.get(&TelemetryProvider::Native); let relay = config.relay.as_ref(); let relay_enabled = relay_provider.is_some(); + if relay_enabled && let Some(relay) = relay { + validate_relay_config(relay)?; + } let providers = [TelemetryProvider::Relay, TelemetryProvider::Native] .into_iter() .filter(|provider| telemetry.providers.contains_key(provider)) @@ -1760,11 +1841,44 @@ fn resolve_telemetry_plan( relay_config: relay_enabled .then(|| resolve_relay_plugin_config(relay)) .flatten(), + relay_dynamic_plugins: if relay_enabled { + relay + .map(|relay| relay.dynamic_plugins.clone()) + .unwrap_or_default() + } else { + Vec::new() + }, native_config: native_provider.and_then(|provider| provider.config.clone()), adapter_outputs, })) } +fn validate_relay_config(relay: &RelayConfig) -> Result<()> { + let Some(observability) = relay.observability.as_ref() else { + return Ok(()); + }; + if observability.version != 2 { + return Err(FabricError::InvalidRelayConfig { + message: "observability version 2 is required; version 1 is unsupported".to_string(), + }); + } + if let Some(atof) = observability.atof.as_ref() { + let legacy_fields = ["output_directory", "filename", "mode", "endpoints"] + .into_iter() + .filter(|field| atof.extensions.contains_key(*field)) + .collect::>(); + if !legacy_fields.is_empty() { + return Err(FabricError::InvalidRelayConfig { + message: format!( + "legacy ATOF fields are unsupported: {}; use version-2 sinks", + legacy_fields.join(", ") + ), + }); + } + } + Ok(()) +} + fn resolve_relay_plugin_config(relay: Option<&RelayConfig>) -> Option { let relay = relay?; let mut components = Vec::new(); @@ -2029,6 +2143,9 @@ pub struct TelemetryPlan { /// Relay pass-through config. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_config: Option, + /// Ordered invocation-scoped Relay dynamic plugins. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relay_dynamic_plugins: Vec, /// Native telemetry pass-through config. #[serde(default, skip_serializing_if = "Option::is_none")] pub native_config: Option, @@ -2216,9 +2333,11 @@ relay: observability: atof: enabled: true - output_directory: ./typed-relay - filename: events.atof.jsonl - mode: overwrite + sinks: + - type: file + output_directory: ./typed-relay + filename: events.atof.jsonl + mode: overwrite atif: enabled: true output_directory: ./typed-relay @@ -2227,6 +2346,16 @@ relay: opentelemetry: enabled: true endpoint: http://localhost:4318/v1/traces + mark_projection: event + attribute_mappings: + - key: nemo_relay.start.metadata.tenant + alias: tenant.id + dynamic_plugins: + - plugin_id: example.fixture + kind: rust_dynamic + manifest_ref: ./plugins/example/relay-plugin.toml + config: + threshold: 3 components: - kind: switchyard enabled: true @@ -2251,7 +2380,7 @@ relay: serde_json::json!("observability") ); assert_eq!( - relay_config["components"][0]["config"]["atof"]["mode"], + relay_config["components"][0]["config"]["atof"]["sinks"][0]["mode"], serde_json::json!("overwrite") ); assert_eq!( @@ -2262,6 +2391,11 @@ relay: relay_config["components"][0]["config"]["opentelemetry"]["endpoint"], serde_json::json!("http://localhost:4318/v1/traces") ); + assert_eq!( + relay_config["components"][0]["config"]["opentelemetry"]["mark_projection"], + serde_json::json!("event") + ); + assert_eq!(plan.relay_dynamic_plugins[0].plugin_id, "example.fixture"); assert_eq!( relay_config["components"][1], serde_json::json!({ @@ -2276,6 +2410,37 @@ relay: ); } + #[test] + fn relay_telemetry_rejects_legacy_observability_contract() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes +runtime: +telemetry: + providers: + relay: {} +relay: + observability: + version: 1 + atof: + enabled: true + output_directory: ./legacy +"#, + ) + .expect("legacy Relay config parses for a clear compatibility error"); + + let error = resolve_telemetry_plan(&config, None).expect_err("legacy config must fail"); + assert!( + error + .to_string() + .contains("observability version 2 is required") + ); + } + #[test] fn telemetry_provider_rejects_unknown_provider_keys() { let result = serde_yaml::from_str::( diff --git a/crates/fabric-core/src/error.rs b/crates/fabric-core/src/error.rs index dbed6db8f..db100a1a2 100644 --- a/crates/fabric-core/src/error.rs +++ b/crates/fabric-core/src/error.rs @@ -169,6 +169,12 @@ pub enum FabricError { /// Why the interpreter cannot be used. reason: String, }, + /// NeMo Relay configuration is incompatible with Fabric's supported contract. + #[error("invalid NeMo Relay configuration: {message}")] + InvalidRelayConfig { + /// Validation message. + message: String, + }, /// A process runner failed to start or complete. #[error("process runner failed for `{command}`: {source}")] ProcessRunner { diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 4443c75e7..6fb016e85 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1888,6 +1888,7 @@ fn prepare_relay_runtime_config( .relay_config .clone() .unwrap_or_else(|| Value::Object(Default::default())), + "dynamic_plugins": telemetry.relay_dynamic_plugins.clone(), }, "fabric": { "agent_name": plan.agent_name.clone(), diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index a9194c666..3d85470cb 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -33,10 +33,12 @@ SPDX-License-Identifier: Apache-2.0 */} - [`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.RelayAtofFileSinkConfig`](./nemo_fabric.models.md#class-relayatoffilesinkconfig): NeMo Relay ATOF filesystem sink configuration. +- [`models.RelayAtofStreamSinkConfig`](./nemo_fabric.models.md#class-relayatofstreamsinkconfig): NeMo Relay ATOF stream sink 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.RelayDynamicPluginConfig`](./nemo_fabric.models.md#class-relaydynamicpluginconfig): One invocation-scoped NeMo Relay dynamic plugin activation. - [`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. 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 540cb5820..67b2e0d4d 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -668,8 +668,68 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- -## class `RelayAtofEndpointConfig` -NeMo Relay ATOF remote endpoint configuration. +## class `RelayAtofFileSinkConfig` +NeMo Relay ATOF filesystem sink 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 `RelayAtofStreamSinkConfig` +NeMo Relay ATOF stream sink configuration. --- @@ -776,6 +836,17 @@ Validate a mapping using this Pydantic model. --- +### classmethod `reject_legacy_atof_fields` + +```python +reject_legacy_atof_fields(value: 'Any') → Any +``` + +Reject Relay 0.5 ATOF fields with a migration-oriented error. + +--- + + ### method `to_mapping` ```python @@ -1117,6 +1188,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 `RelayDynamicPluginConfig` +One invocation-scoped NeMo Relay dynamic plugin activation. + + +--- + +### 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. @@ -1566,6 +1697,7 @@ enable_relay( output_dir: 'str | Path | None' = None, observability: 'RelayObservabilityConfig | Mapping[str, Any] | None' = None, components: 'Sequence[RelayComponentConfig | Mapping[str, Any]] | None' = None, + dynamic_plugins: 'Sequence[RelayDynamicPluginConfig | Mapping[str, Any]] | None' = None, policy: 'RelayConfigPolicy | Mapping[str, Any] | None' = None ) → Self ``` diff --git a/examples/code_review_agent/config.py b/examples/code_review_agent/config.py index ad4bca58a..cfb67ac17 100644 --- a/examples/code_review_agent/config.py +++ b/examples/code_review_agent/config.py @@ -14,6 +14,7 @@ from nemo_fabric import ModelConfig from nemo_fabric import RelayAtifConfig from nemo_fabric import RelayAtofConfig +from nemo_fabric import RelayAtofFileSinkConfig from nemo_fabric import RelayObservabilityConfig from nemo_fabric import RelayOtlpConfig from nemo_fabric import RuntimeConfig @@ -240,9 +241,13 @@ def with_relay(base: FabricConfig) -> FabricConfig: ), atof=RelayAtofConfig( enabled=True, - output_directory="./artifacts/relay", - filename="events.atof.jsonl", - mode="overwrite", + sinks=[ + RelayAtofFileSinkConfig( + output_directory="./artifacts/relay", + filename="events.atof.jsonl", + mode="overwrite", + ) + ], ), ), ) @@ -293,7 +298,13 @@ def with_relay_openinference(base: FabricConfig) -> FabricConfig: if isinstance(observability.atif, RelayAtifConfig): observability.atif.output_directory = "./artifacts/relay-openinference" if isinstance(observability.atof, RelayAtofConfig): - observability.atof.output_directory = "./artifacts/relay-openinference" + observability.atof.sinks = [ + RelayAtofFileSinkConfig( + output_directory="./artifacts/relay-openinference", + filename="events.atof.jsonl", + mode="overwrite", + ) + ] return config @@ -316,7 +327,9 @@ def with_native_otel(base: FabricConfig) -> FabricConfig: "enabled": True, "transport": "http_binary", "endpoint": "http://localhost:4318/v1/traces", - "resource_attributes": {"deployment.environment": "dev"}, + "resource_attributes": { + "deployment.environment": "dev" + }, }, }, }, 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 f17f88557..33b57d9e0 100644 --- a/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml +++ b/examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml @@ -47,10 +47,25 @@ relay: agent_name: harbor-calculator-demo atof: enabled: true - output_directory: /logs/agent/fabric-artifacts/hermes-relay/relay - filename: events.atof.jsonl - mode: overwrite + sinks: + - type: file + 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 + components: + - kind: pii_redaction + enabled: true + config: + version: 1 + mode: builtin + input: true + output: true + mark: true + codec: openai_chat + builtin: + detector: email + action: mask diff --git a/pyproject.toml b/pyproject.toml index a8b5fa02d..2c9a5f559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ hermes = [ ] relay = [ - "nemo-relay~=0.5.0", + "nemo-relay~=0.6.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 96cceeaa0..744b61810 100644 --- a/python/src/nemo_fabric/__init__.py +++ b/python/src/nemo_fabric/__init__.py @@ -22,10 +22,12 @@ 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 RelayAtofFileSinkConfig +from nemo_fabric.models import RelayAtofStreamSinkConfig from nemo_fabric.models import RelayComponentConfig from nemo_fabric.models import RelayConfig from nemo_fabric.models import RelayConfigPolicy +from nemo_fabric.models import RelayDynamicPluginConfig from nemo_fabric.models import RelayHttpStorageConfig from nemo_fabric.models import RelayObservabilityConfig from nemo_fabric.models import RelayOtlpConfig @@ -78,9 +80,11 @@ "ProfileRegistryConfig", "RelayAtifConfig", "RelayAtofConfig", - "RelayAtofEndpointConfig", + "RelayAtofFileSinkConfig", + "RelayAtofStreamSinkConfig", "RelayComponentConfig", "RelayConfigPolicy", + "RelayDynamicPluginConfig", "RelayHttpStorageConfig", "RelayObservabilityConfig", "RelayOtlpConfig", diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index ee6b956a8..c0756e64c 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -240,12 +240,24 @@ class RelayConfigPolicy(FabricBaseModel): unsupported_value: Literal["ignore", "warn", "error"] = "error" -class RelayAtofEndpointConfig(FabricBaseModel): - """NeMo Relay ATOF remote endpoint configuration.""" +class RelayAtofFileSinkConfig(FabricBaseModel): + """NeMo Relay ATOF filesystem sink configuration.""" + type: Literal["file"] = "file" + output_directory: str | Path | None = None + filename: str | None = None + mode: Literal["append", "overwrite"] = "append" + + +class RelayAtofStreamSinkConfig(FabricBaseModel): + """NeMo Relay ATOF stream sink configuration.""" + + type: Literal["stream"] = "stream" + name: str | None = None url: str transport: Literal["http_post", "websocket", "ndjson"] = "http_post" headers: dict[str, str] = Field(default_factory=dict) + header_env: dict[str, str] = Field(default_factory=dict) timeout_millis: int = 3000 field_name_policy: Literal["preserve", "replace_dots"] = "preserve" @@ -254,10 +266,28 @@ 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 + sinks: list[ + Annotated[ + RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig, + Field(discriminator="type"), + ] + | dict[str, Any] + ] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def reject_legacy_atof_fields(cls, value: Any) -> Any: + """Reject Relay 0.5 ATOF fields with a migration-oriented error.""" + + if isinstance(value, Mapping): + legacy = sorted( + set(value) & {"output_directory", "filename", "mode", "endpoints"} + ) + if legacy: + raise ValueError( + f"legacy ATOF fields are unsupported: {', '.join(legacy)}; use version-2 sinks" + ) + return value class RelayS3StorageConfig(FabricBaseModel): @@ -311,6 +341,8 @@ class RelayOtlpConfig(FabricBaseModel): """NeMo Relay OTLP export configuration for OpenTelemetry/OpenInference.""" enabled: bool = False + mark_projection: Literal["inherit", "event", "tool"] = "inherit" + mark_exclude_names: list[str] = Field(default_factory=lambda: ["llm.chunk"]) transport: Literal["http_binary", "grpc"] = "http_binary" endpoint: str | None = None headers: dict[str, str] = Field(default_factory=dict) @@ -320,12 +352,13 @@ class RelayOtlpConfig(FabricBaseModel): service_version: str | None = None instrumentation_scope: str | None = None timeout_millis: int = 3000 + attribute_mappings: list[dict[str, str]] = Field(default_factory=list) class RelayObservabilityConfig(FabricBaseModel): """NeMo Relay observability component configuration.""" - version: int = 1 + version: Literal[2] = 2 atof: RelayAtofConfig | dict[str, Any] | None = None atif: RelayAtifConfig | dict[str, Any] | None = None opentelemetry: RelayOtlpConfig | dict[str, Any] | None = None @@ -341,13 +374,28 @@ class RelayComponentConfig(FabricBaseModel): config: dict[str, Any] = Field(default_factory=dict) +class RelayDynamicPluginConfig(FabricBaseModel): + """One invocation-scoped NeMo Relay dynamic plugin activation.""" + + plugin_id: str = Field(min_length=1) + kind: Literal["rust_dynamic", "worker"] + manifest_ref: str | Path + environment_ref: str | Path | None = None + 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) + components: list[RelayComponentConfig | dict[str, Any]] = Field( + default_factory=list + ) + dynamic_plugins: list[RelayDynamicPluginConfig | dict[str, Any]] = Field( + default_factory=list + ) policy: RelayConfigPolicy | dict[str, Any] | None = None @@ -360,7 +408,9 @@ class TelemetryProviderConfig(FabricBaseModel): class TelemetryConfig(FabricBaseModel): """Telemetry configuration.""" - providers: dict[Literal["relay", "native"], TelemetryProviderConfig | dict[str, Any]] = Field(default_factory=dict) + providers: dict[ + Literal["relay", "native"], TelemetryProviderConfig | dict[str, Any] + ] = Field(default_factory=dict) def enable_relay( self, @@ -497,6 +547,8 @@ def enable_relay( output_dir: str | Path | None = None, observability: RelayObservabilityConfig | Mapping[str, Any] | None = None, components: Sequence[RelayComponentConfig | Mapping[str, Any]] | None = None, + dynamic_plugins: Sequence[RelayDynamicPluginConfig | Mapping[str, Any]] + | None = None, policy: RelayConfigPolicy | Mapping[str, Any] | None = None, ) -> Self: """Enable NeMo Relay telemetry and return this config.""" @@ -516,12 +568,24 @@ def enable_relay( relay.output_dir = output_dir if observability is not None: relay.observability = ( - observability if isinstance(observability, RelayObservabilityConfig) else dict(observability) + observability + if isinstance(observability, RelayObservabilityConfig) + else dict(observability) ) if components is not None: - relay.components = [item if isinstance(item, RelayComponentConfig) else dict(item) for item in components] + relay.components = [ + item if isinstance(item, RelayComponentConfig) else dict(item) + for item in components + ] + if dynamic_plugins is not None: + relay.dynamic_plugins = [ + item if isinstance(item, RelayDynamicPluginConfig) else dict(item) + for item in dynamic_plugins + ] if policy is not None: - relay.policy = policy if isinstance(policy, RelayConfigPolicy) else dict(policy) + relay.policy = ( + policy if isinstance(policy, RelayConfigPolicy) else dict(policy) + ) self.relay = relay return self diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index d1624c1ca..86ebfcee0 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -847,71 +847,14 @@ "description": "Whether ATOF export is enabled.", "type": "boolean" }, - "endpoints": { - "description": "Optional remote ATOF endpoints.", + "sinks": { + "description": "Ordered ATOF destinations.", "items": { - "$ref": "#/$defs/RelayAtofEndpointConfig" + "$ref": "#/$defs/RelayAtofSinkConfig" }, "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": { @@ -964,6 +907,99 @@ } ] }, + "RelayAtofSinkConfig": { + "description": "Relay ATOF destination.", + "oneOf": [ + { + "description": "Filesystem ATOF JSONL destination.", + "properties": { + "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": { + "const": "file", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Remote streaming ATOF destination.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed endpoint headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static endpoint headers.", + "type": "object" + }, + "name": { + "description": "Optional stable sink name.", + "type": [ + "string", + "null" + ] + }, + "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." + }, + "type": { + "const": "stream", + "type": "string" + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + ] + }, "RelayComponentConfig": { "additionalProperties": true, "description": "Generic NeMo Relay plugin component configuration.", @@ -999,6 +1035,13 @@ }, "type": "array" }, + "dynamic_plugins": { + "description": "Ordered manifest-backed plugins activated for this run.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "observability": { "anyOf": [ { @@ -1059,6 +1102,76 @@ }, "type": "object" }, + "RelayDynamicPluginConfig": { + "description": "One invocation-scoped NeMo Relay dynamic plugin activation.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local plugin configuration.", + "type": "object" + }, + "environment_ref": { + "description": "Optional lifecycle-managed environment path.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/$defs/RelayDynamicPluginKind", + "description": "Dynamic plugin execution lane." + }, + "manifest_ref": { + "description": "Path to the authored `relay-plugin.toml`.", + "type": "string" + }, + "plugin_id": { + "description": "Canonical plugin identifier declared by the manifest.", + "type": "string" + } + }, + "required": [ + "plugin_id", + "kind", + "manifest_ref" + ], + "type": "object" + }, + "RelayDynamicPluginKind": { + "description": "Relay dynamic plugin execution lane.", + "oneOf": [ + { + "const": "rust_dynamic", + "description": "In-process native Rust dynamic library.", + "type": "string" + }, + { + "const": "worker", + "description": "Out-of-process worker plugin.", + "type": "string" + } + ] + }, + "RelayMarkProjection": { + "description": "Relay mark projection shape for OTLP exporters.", + "oneOf": [ + { + "const": "inherit", + "description": "Preserve each mark's requested projection.", + "type": "string" + }, + { + "const": "event", + "description": "Export marks as span events.", + "type": "string" + }, + { + "const": "tool", + "description": "Export marks as tool spans.", + "type": "string" + } + ] + }, "RelayObservabilityConfig": { "additionalProperties": true, "description": "NeMo Relay observability component configuration.", @@ -1119,7 +1232,7 @@ "description": "Relay config validation policy." }, "version": { - "default": 1, + "default": 2, "description": "Relay observability config version.", "format": "uint32", "minimum": 0, @@ -1128,10 +1241,35 @@ }, "type": "object" }, + "RelayOtlpAttributeMapping": { + "description": "One Relay event-field to OTLP attribute mapping.", + "properties": { + "alias": { + "description": "Exported OTLP attribute alias.", + "type": "string" + }, + "key": { + "description": "Canonical Relay event-field key.", + "type": "string" + } + }, + "required": [ + "key", + "alias" + ], + "type": "object" + }, "RelayOtlpConfig": { "additionalProperties": true, "description": "Relay OpenTelemetry/OpenInference export configuration.", "properties": { + "attribute_mappings": { + "description": "Typed Relay-event attribute mappings.", + "items": { + "$ref": "#/$defs/RelayOtlpAttributeMapping" + }, + "type": "array" + }, "enabled": { "default": false, "description": "Whether OTLP export is enabled.", @@ -1158,6 +1296,21 @@ "null" ] }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Mark names excluded from projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayMarkProjection", + "default": "inherit", + "description": "Projection shape for Relay marks." + }, "resource_attributes": { "additionalProperties": { "type": "string" @@ -1450,6 +1603,13 @@ "relay_config": { "description": "Relay pass-through config." }, + "relay_dynamic_plugins": { + "description": "Ordered invocation-scoped Relay dynamic plugins.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "relay_enabled": { "description": "Whether Relay is enabled.", "type": "boolean" diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index f664b81ed..c2195844d 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -424,71 +424,14 @@ "description": "Whether ATOF export is enabled.", "type": "boolean" }, - "endpoints": { - "description": "Optional remote ATOF endpoints.", + "sinks": { + "description": "Ordered ATOF destinations.", "items": { - "$ref": "#/$defs/RelayAtofEndpointConfig" + "$ref": "#/$defs/RelayAtofSinkConfig" }, "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": { @@ -541,6 +484,99 @@ } ] }, + "RelayAtofSinkConfig": { + "description": "Relay ATOF destination.", + "oneOf": [ + { + "description": "Filesystem ATOF JSONL destination.", + "properties": { + "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": { + "const": "file", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Remote streaming ATOF destination.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed endpoint headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static endpoint headers.", + "type": "object" + }, + "name": { + "description": "Optional stable sink name.", + "type": [ + "string", + "null" + ] + }, + "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." + }, + "type": { + "const": "stream", + "type": "string" + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + ] + }, "RelayComponentConfig": { "additionalProperties": true, "description": "Generic NeMo Relay plugin component configuration.", @@ -576,6 +612,13 @@ }, "type": "array" }, + "dynamic_plugins": { + "description": "Ordered manifest-backed plugins activated for this run.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "observability": { "anyOf": [ { @@ -636,6 +679,76 @@ }, "type": "object" }, + "RelayDynamicPluginConfig": { + "description": "One invocation-scoped NeMo Relay dynamic plugin activation.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local plugin configuration.", + "type": "object" + }, + "environment_ref": { + "description": "Optional lifecycle-managed environment path.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/$defs/RelayDynamicPluginKind", + "description": "Dynamic plugin execution lane." + }, + "manifest_ref": { + "description": "Path to the authored `relay-plugin.toml`.", + "type": "string" + }, + "plugin_id": { + "description": "Canonical plugin identifier declared by the manifest.", + "type": "string" + } + }, + "required": [ + "plugin_id", + "kind", + "manifest_ref" + ], + "type": "object" + }, + "RelayDynamicPluginKind": { + "description": "Relay dynamic plugin execution lane.", + "oneOf": [ + { + "const": "rust_dynamic", + "description": "In-process native Rust dynamic library.", + "type": "string" + }, + { + "const": "worker", + "description": "Out-of-process worker plugin.", + "type": "string" + } + ] + }, + "RelayMarkProjection": { + "description": "Relay mark projection shape for OTLP exporters.", + "oneOf": [ + { + "const": "inherit", + "description": "Preserve each mark's requested projection.", + "type": "string" + }, + { + "const": "event", + "description": "Export marks as span events.", + "type": "string" + }, + { + "const": "tool", + "description": "Export marks as tool spans.", + "type": "string" + } + ] + }, "RelayObservabilityConfig": { "additionalProperties": true, "description": "NeMo Relay observability component configuration.", @@ -696,7 +809,7 @@ "description": "Relay config validation policy." }, "version": { - "default": 1, + "default": 2, "description": "Relay observability config version.", "format": "uint32", "minimum": 0, @@ -705,10 +818,35 @@ }, "type": "object" }, + "RelayOtlpAttributeMapping": { + "description": "One Relay event-field to OTLP attribute mapping.", + "properties": { + "alias": { + "description": "Exported OTLP attribute alias.", + "type": "string" + }, + "key": { + "description": "Canonical Relay event-field key.", + "type": "string" + } + }, + "required": [ + "key", + "alias" + ], + "type": "object" + }, "RelayOtlpConfig": { "additionalProperties": true, "description": "Relay OpenTelemetry/OpenInference export configuration.", "properties": { + "attribute_mappings": { + "description": "Typed Relay-event attribute mappings.", + "items": { + "$ref": "#/$defs/RelayOtlpAttributeMapping" + }, + "type": "array" + }, "enabled": { "default": false, "description": "Whether OTLP export is enabled.", @@ -735,6 +873,21 @@ "null" ] }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Mark names excluded from projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayMarkProjection", + "default": "inherit", + "description": "Projection shape for Relay marks." + }, "resource_attributes": { "additionalProperties": { "type": "string" diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index 6fc533a5b..0a6452f74 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -530,71 +530,14 @@ "description": "Whether ATOF export is enabled.", "type": "boolean" }, - "endpoints": { - "description": "Optional remote ATOF endpoints.", + "sinks": { + "description": "Ordered ATOF destinations.", "items": { - "$ref": "#/$defs/RelayAtofEndpointConfig" + "$ref": "#/$defs/RelayAtofSinkConfig" }, "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": { @@ -647,6 +590,99 @@ } ] }, + "RelayAtofSinkConfig": { + "description": "Relay ATOF destination.", + "oneOf": [ + { + "description": "Filesystem ATOF JSONL destination.", + "properties": { + "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": { + "const": "file", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Remote streaming ATOF destination.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed endpoint headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static endpoint headers.", + "type": "object" + }, + "name": { + "description": "Optional stable sink name.", + "type": [ + "string", + "null" + ] + }, + "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." + }, + "type": { + "const": "stream", + "type": "string" + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + ] + }, "RelayComponentConfig": { "additionalProperties": true, "description": "Generic NeMo Relay plugin component configuration.", @@ -682,6 +718,13 @@ }, "type": "array" }, + "dynamic_plugins": { + "description": "Ordered manifest-backed plugins activated for this run.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "observability": { "anyOf": [ { @@ -742,6 +785,76 @@ }, "type": "object" }, + "RelayDynamicPluginConfig": { + "description": "One invocation-scoped NeMo Relay dynamic plugin activation.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local plugin configuration.", + "type": "object" + }, + "environment_ref": { + "description": "Optional lifecycle-managed environment path.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/$defs/RelayDynamicPluginKind", + "description": "Dynamic plugin execution lane." + }, + "manifest_ref": { + "description": "Path to the authored `relay-plugin.toml`.", + "type": "string" + }, + "plugin_id": { + "description": "Canonical plugin identifier declared by the manifest.", + "type": "string" + } + }, + "required": [ + "plugin_id", + "kind", + "manifest_ref" + ], + "type": "object" + }, + "RelayDynamicPluginKind": { + "description": "Relay dynamic plugin execution lane.", + "oneOf": [ + { + "const": "rust_dynamic", + "description": "In-process native Rust dynamic library.", + "type": "string" + }, + { + "const": "worker", + "description": "Out-of-process worker plugin.", + "type": "string" + } + ] + }, + "RelayMarkProjection": { + "description": "Relay mark projection shape for OTLP exporters.", + "oneOf": [ + { + "const": "inherit", + "description": "Preserve each mark's requested projection.", + "type": "string" + }, + { + "const": "event", + "description": "Export marks as span events.", + "type": "string" + }, + { + "const": "tool", + "description": "Export marks as tool spans.", + "type": "string" + } + ] + }, "RelayObservabilityConfig": { "additionalProperties": true, "description": "NeMo Relay observability component configuration.", @@ -802,7 +915,7 @@ "description": "Relay config validation policy." }, "version": { - "default": 1, + "default": 2, "description": "Relay observability config version.", "format": "uint32", "minimum": 0, @@ -811,10 +924,35 @@ }, "type": "object" }, + "RelayOtlpAttributeMapping": { + "description": "One Relay event-field to OTLP attribute mapping.", + "properties": { + "alias": { + "description": "Exported OTLP attribute alias.", + "type": "string" + }, + "key": { + "description": "Canonical Relay event-field key.", + "type": "string" + } + }, + "required": [ + "key", + "alias" + ], + "type": "object" + }, "RelayOtlpConfig": { "additionalProperties": true, "description": "Relay OpenTelemetry/OpenInference export configuration.", "properties": { + "attribute_mappings": { + "description": "Typed Relay-event attribute mappings.", + "items": { + "$ref": "#/$defs/RelayOtlpAttributeMapping" + }, + "type": "array" + }, "enabled": { "default": false, "description": "Whether OTLP export is enabled.", @@ -841,6 +979,21 @@ "null" ] }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Mark names excluded from projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayMarkProjection", + "default": "inherit", + "description": "Projection shape for Relay marks." + }, "resource_attributes": { "additionalProperties": { "type": "string" diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index a5f6efd4e..1ef90cbd7 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -997,71 +997,14 @@ "description": "Whether ATOF export is enabled.", "type": "boolean" }, - "endpoints": { - "description": "Optional remote ATOF endpoints.", + "sinks": { + "description": "Ordered ATOF destinations.", "items": { - "$ref": "#/$defs/RelayAtofEndpointConfig" + "$ref": "#/$defs/RelayAtofSinkConfig" }, "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": { @@ -1114,6 +1057,99 @@ } ] }, + "RelayAtofSinkConfig": { + "description": "Relay ATOF destination.", + "oneOf": [ + { + "description": "Filesystem ATOF JSONL destination.", + "properties": { + "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": { + "const": "file", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "description": "Remote streaming ATOF destination.", + "properties": { + "field_name_policy": { + "$ref": "#/$defs/RelayAtofEndpointFieldNamePolicy", + "default": "preserve", + "description": "Field-name handling policy." + }, + "header_env": { + "additionalProperties": { + "type": "string" + }, + "description": "Environment-variable-backed endpoint headers.", + "type": "object" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "Static endpoint headers.", + "type": "object" + }, + "name": { + "description": "Optional stable sink name.", + "type": [ + "string", + "null" + ] + }, + "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." + }, + "type": { + "const": "stream", + "type": "string" + }, + "url": { + "description": "Endpoint URL.", + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "type": "object" + } + ] + }, "RelayComponentConfig": { "additionalProperties": true, "description": "Generic NeMo Relay plugin component configuration.", @@ -1149,6 +1185,13 @@ }, "type": "array" }, + "dynamic_plugins": { + "description": "Ordered manifest-backed plugins activated for this run.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "observability": { "anyOf": [ { @@ -1209,6 +1252,76 @@ }, "type": "object" }, + "RelayDynamicPluginConfig": { + "description": "One invocation-scoped NeMo Relay dynamic plugin activation.", + "properties": { + "config": { + "additionalProperties": true, + "description": "Component-local plugin configuration.", + "type": "object" + }, + "environment_ref": { + "description": "Optional lifecycle-managed environment path.", + "type": [ + "string", + "null" + ] + }, + "kind": { + "$ref": "#/$defs/RelayDynamicPluginKind", + "description": "Dynamic plugin execution lane." + }, + "manifest_ref": { + "description": "Path to the authored `relay-plugin.toml`.", + "type": "string" + }, + "plugin_id": { + "description": "Canonical plugin identifier declared by the manifest.", + "type": "string" + } + }, + "required": [ + "plugin_id", + "kind", + "manifest_ref" + ], + "type": "object" + }, + "RelayDynamicPluginKind": { + "description": "Relay dynamic plugin execution lane.", + "oneOf": [ + { + "const": "rust_dynamic", + "description": "In-process native Rust dynamic library.", + "type": "string" + }, + { + "const": "worker", + "description": "Out-of-process worker plugin.", + "type": "string" + } + ] + }, + "RelayMarkProjection": { + "description": "Relay mark projection shape for OTLP exporters.", + "oneOf": [ + { + "const": "inherit", + "description": "Preserve each mark's requested projection.", + "type": "string" + }, + { + "const": "event", + "description": "Export marks as span events.", + "type": "string" + }, + { + "const": "tool", + "description": "Export marks as tool spans.", + "type": "string" + } + ] + }, "RelayObservabilityConfig": { "additionalProperties": true, "description": "NeMo Relay observability component configuration.", @@ -1269,7 +1382,7 @@ "description": "Relay config validation policy." }, "version": { - "default": 1, + "default": 2, "description": "Relay observability config version.", "format": "uint32", "minimum": 0, @@ -1278,10 +1391,35 @@ }, "type": "object" }, + "RelayOtlpAttributeMapping": { + "description": "One Relay event-field to OTLP attribute mapping.", + "properties": { + "alias": { + "description": "Exported OTLP attribute alias.", + "type": "string" + }, + "key": { + "description": "Canonical Relay event-field key.", + "type": "string" + } + }, + "required": [ + "key", + "alias" + ], + "type": "object" + }, "RelayOtlpConfig": { "additionalProperties": true, "description": "Relay OpenTelemetry/OpenInference export configuration.", "properties": { + "attribute_mappings": { + "description": "Typed Relay-event attribute mappings.", + "items": { + "$ref": "#/$defs/RelayOtlpAttributeMapping" + }, + "type": "array" + }, "enabled": { "default": false, "description": "Whether OTLP export is enabled.", @@ -1308,6 +1446,21 @@ "null" ] }, + "mark_exclude_names": { + "default": [ + "llm.chunk" + ], + "description": "Mark names excluded from projection.", + "items": { + "type": "string" + }, + "type": "array" + }, + "mark_projection": { + "$ref": "#/$defs/RelayMarkProjection", + "default": "inherit", + "description": "Projection shape for Relay marks." + }, "resource_attributes": { "additionalProperties": { "type": "string" @@ -1558,6 +1711,13 @@ "relay_config": { "description": "Relay pass-through config." }, + "relay_dynamic_plugins": { + "description": "Ordered invocation-scoped Relay dynamic plugins.", + "items": { + "$ref": "#/$defs/RelayDynamicPluginConfig" + }, + "type": "array" + }, "relay_enabled": { "description": "Whether Relay is enabled.", "type": "boolean" diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index 7b4716f40..fa8fe8975 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -71,7 +71,9 @@ def test_virtualenv_subprocess_env_preserves_environment_outside_virtualenv( def test_request_payload(): - assert common_utils.request_payload({"request": {"input": "hello"}}) == {"input": "hello"} + assert common_utils.request_payload({"request": {"input": "hello"}}) == { + "input": "hello" + } assert common_utils.request_payload({}) == {} @@ -95,12 +97,18 @@ def test_default_base_url( [ ( {"base_url": "https://settings.example/v1"}, - {"provider": "nvidia", "settings": {"base_url": "https://model.example/v1"}}, + { + "provider": "nvidia", + "settings": {"base_url": "https://model.example/v1"}, + }, "https://settings.example/v1", ), ( {}, - {"provider": "openai", "settings": {"base_url": "https://model.example/v1"}}, + { + "provider": "openai", + "settings": {"base_url": "https://model.example/v1"}, + }, "https://model.example/v1", ), ({}, {"provider": "nvidia"}, "https://integrate.api.nvidia.com/v1"), @@ -179,10 +187,14 @@ def test_payload_accessors_prefer_effective_config(): assert common_utils.agent_name(payload) == "effective-agent" assert common_utils.config_root(payload) == "/effective" assert common_utils.runtime_context(payload) == payload["runtime_context"] - assert common_utils.environment_payload(payload) == {"workspace": "/runtime-workspace"} + assert common_utils.environment_payload(payload) == { + "workspace": "/runtime-workspace" + } assert common_utils.settings_payload(payload) == {"inner": True} assert common_utils.models_payload(payload) == {"inner": {"model": "inner-model"}} - assert common_utils.capability_plan(payload) == {"native": {"skill_paths": ["skills"]}} + assert common_utils.capability_plan(payload) == { + "native": {"skill_paths": ["skills"]} + } def test_load_payload_reads_fabric_invocation(tmp_path: Path): @@ -251,11 +263,15 @@ def fake_import(name: str, *args: object, **kwargs: object) -> object: monkeypatch.setattr(builtins, "__import__", fake_import) - assert common_utils.dump_yaml({"model": {"default": "demo"}}) == json.dumps( - {"model": {"default": "demo"}}, - indent=2, - sort_keys=False, - ) + "\n" + assert ( + common_utils.dump_yaml({"model": {"default": "demo"}}) + == json.dumps( + {"model": {"default": "demo"}}, + indent=2, + sort_keys=False, + ) + + "\n" + ) @pytest.mark.parametrize( @@ -272,7 +288,10 @@ def test_normalize_list(value: object, expected: list[str]): def test_without_none(): - assert common_utils.without_none({"a": 1, "b": None, "c": False}) == {"a": 1, "c": False} + assert common_utils.without_none({"a": 1, "b": None, "c": False}) == { + "a": 1, + "c": False, + } def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config( @@ -286,7 +305,12 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config "config": { "atof": { "enabled": True, - "output_directory": "custom-relay", + "sinks": [ + { + "type": "file", + "output_directory": "custom-relay", + } + ], }, "atif": {"enabled": True}, } @@ -301,9 +325,7 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config previous_atof_dir.mkdir(parents=True) previous_atif_dir.mkdir(parents=True) (previous_atof_dir / "events.atof.jsonl").write_text("{}", encoding="utf-8") - (previous_atif_dir / "trajectory-old.atif.json").write_text( - "{}", encoding="utf-8" - ) + (previous_atif_dir / "trajectory-old.atif.json").write_text("{}", encoding="utf-8") payload = { "effective_config": { "agent_name": "review-agent", @@ -321,24 +343,28 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config assert plugin_config["version"] == 1 assert plugin_config["components"][0]["kind"] == "observability" - assert observability["atof"]["output_directory"] == str( + atof_sink = observability["atof"]["sinks"][0] + assert observability["version"] == 2 + assert atof_sink["output_directory"] == str( tmp_path / "custom-relay" / "runtime-current" ) - assert observability["atof"]["filename"] == "events.atof.jsonl" - assert observability["atof"]["mode"] == "overwrite" - assert Path(observability["atof"]["output_directory"]).is_dir() + assert atof_sink["filename"] == "events.atof.jsonl" + assert atof_sink["mode"] == "overwrite" + assert Path(atof_sink["output_directory"]).is_dir() assert observability["atif"]["output_directory"] == str( tmp_path / "artifacts" / "relay" / "runtime-current" ) - assert observability["atif"]["filename_template"] == "trajectory-{session_id}.atif.json" + assert ( + observability["atif"]["filename_template"] + == "trajectory-{session_id}.atif.json" + ) assert observability["atif"]["agent_name"] == "review-agent" assert observability["atif"]["model_name"] == "nvidia/review-model" assert Path(observability["atif"]["output_directory"]).is_dir() - atof_file = Path(observability["atof"]["output_directory"]) / "events.atof.jsonl" + atof_file = Path(atof_sink["output_directory"]) / "events.atof.jsonl" atif_file = ( - Path(observability["atif"]["output_directory"]) - / "trajectory-current.atif.json" + Path(observability["atif"]["output_directory"]) / "trajectory-current.atif.json" ) atof_file.write_text("{}", encoding="utf-8") atif_file.write_text("{}", encoding="utf-8") @@ -365,7 +391,15 @@ def test_collect_relay_artifacts(tmp_path: Path): { "kind": "observability", "config": { - "atof": {"enabled": True, "output_directory": str(atof_dir)}, + "atof": { + "enabled": True, + "sinks": [ + { + "type": "file", + "output_directory": str(atof_dir), + } + ], + }, "atif": {"enabled": True, "output_directory": str(atif_dir)}, }, } @@ -378,6 +412,175 @@ def test_collect_relay_artifacts(tmp_path: Path): ] +def test_collect_relay_artifacts_honors_file_sink_filename(tmp_path: Path): + atof_dir = tmp_path / "atof" + atof_dir.mkdir() + configured = atof_dir / "events.atof" + ignored = atof_dir / "unrelated.jsonl" + configured.write_text("{}", encoding="utf-8") + ignored.write_text("{}", encoding="utf-8") + plugin_config = { + "components": [ + { + "kind": "observability", + "config": { + "atof": { + "enabled": True, + "sinks": [ + { + "type": "file", + "output_directory": str(atof_dir), + "filename": configured.name, + } + ], + } + }, + } + ] + } + + assert common_utils.collect_relay_artifacts(plugin_config) == [ + {"kind": "atof", "path": str(configured)} + ] + + +def test_load_relay_dynamic_plugins_preserves_order_and_resolves_paths(tmp_path: Path): + config_path = tmp_path / "relay.json" + config_path.write_text( + json.dumps( + { + "relay": { + "dynamic_plugins": [ + { + "plugin_id": "example.native", + "kind": "rust_dynamic", + "manifest_ref": "plugins/native/relay-plugin.toml", + "config": {"enabled": True}, + }, + { + "plugin_id": "example.worker", + "kind": "worker", + "manifest_ref": "/opt/plugins/worker/relay-plugin.toml", + "environment_ref": "environments/worker", + }, + ] + } + } + ), + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(config_path) + payload = {"effective_config": {"config_root": str(tmp_path)}} + + specs = common_utils.load_relay_dynamic_plugins(payload) + + assert [spec["plugin_id"] for spec in specs] == ["example.native", "example.worker"] + assert specs[0]["manifest_ref"] == str( + tmp_path / "plugins" / "native" / "relay-plugin.toml" + ) + assert specs[1]["manifest_ref"] == "/opt/plugins/worker/relay-plugin.toml" + assert specs[1]["environment_ref"] == str(tmp_path / "environments" / "worker") + + +def test_relay_api_plugin_config_uses_observability_v2_and_first_party_pii( + monkeypatch: pytest.MonkeyPatch, +): + pytest.importorskip("nemo_relay") + monkeypatch.setenv("RELAY_TOKEN", "test-only-value") + config = common_utils.relay_api_plugin_config( + { + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": True, + "config": { + "version": 2, + "atof": { + "enabled": True, + "sinks": [ + { + "type": "file", + "output_directory": "/tmp/relay-atof", + "filename": "events.jsonl", + "mode": "overwrite", + }, + { + "type": "stream", + "name": "review", + "url": "https://example.test/events", + "header_env": {"authorization": "RELAY_TOKEN"}, + "field_name_policy": "preserve", + }, + ], + }, + "openinference": { + "enabled": True, + "mark_projection": "event", + "mark_exclude_names": ["llm.chunk"], + "attribute_mappings": [ + { + "key": "nemo_relay.mark.metadata.source", + "alias": "source.id", + } + ], + }, + }, + }, + { + "kind": "pii_redaction", + "enabled": True, + "config": { + "version": 1, + "mode": "builtin", + "input": True, + "output": True, + "mark": True, + "codec": "openai_chat", + "builtin": {"detector": "email", "action": "mask"}, + }, + }, + ], + } + ) + + rendered = config.to_dict() + observability = rendered["components"][0]["config"] + assert observability["version"] == 2 + assert [sink["type"] for sink in observability["atof"]["sinks"]] == [ + "file", + "stream", + ] + assert observability["openinference"]["mark_projection"] == "event" + assert observability["openinference"]["attribute_mappings"] == [ + {"key": "nemo_relay.mark.metadata.source", "alias": "source.id"} + ] + assert rendered["components"][1]["kind"] == "pii_redaction" + + +def test_relay_api_dynamic_plugins_match_relay_owned_host_contract(tmp_path: Path): + pytest.importorskip("nemo_relay") + specs = common_utils.relay_api_dynamic_plugins( + [ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "manifest_ref": str(tmp_path / "relay-plugin.toml"), + "config": {"mode": "test"}, + } + ] + ) + + assert [spec.to_dict() for spec in specs] == [ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "manifest_ref": str(tmp_path / "relay-plugin.toml"), + "config": {"mode": "test"}, + } + ] + + @pytest.mark.parametrize( ("relay_config", "plugin_config", "expected_names"), [ @@ -411,7 +614,7 @@ def test_write_relay_configs( assert tomllib.load(stream) == config -def test_write_relay_configs_migrates_atof_to_current_cli_contract(tmp_path: Path): +def test_write_relay_configs_preserves_current_cli_contract(tmp_path: Path): os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") plugin_config = { "version": 1, @@ -420,21 +623,25 @@ def test_write_relay_configs_migrates_atof_to_current_cli_contract(tmp_path: Pat "kind": "observability", "enabled": True, "config": { - "version": 1, + "version": 2, "atof": { "enabled": True, - "output_directory": "/tmp/atof", - "filename": "events.jsonl", - "mode": "overwrite", - "endpoints": [ + "sinks": [ + { + "type": "file", + "output_directory": "/tmp/atof", + "filename": "events.jsonl", + "mode": "overwrite", + }, { + "type": "stream", "url": "https://example.test/events", "transport": "http_post", "headers": {"x-test": "value"}, "header_env": {"authorization": "TOKEN"}, "timeout_millis": 1000, "field_name_policy": "replace_dots", - } + }, ], }, "atif": {"enabled": True, "output_directory": "/tmp/atif"}, @@ -477,5 +684,82 @@ def test_write_relay_configs_migrates_atof_to_current_cli_contract(tmp_path: Pat "enabled": True, "output_directory": "/tmp/atif", } - assert plugin_config["components"][0]["config"]["version"] == 1 - assert "sinks" not in plugin_config["components"][0]["config"]["atof"] + + +def test_write_relay_configs_rejects_legacy_observability(tmp_path: Path): + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") + plugin_config = { + "version": 1, + "components": [ + { + "kind": "observability", + "config": {"version": 1, "atof": {"enabled": True}}, + } + ], + } + + with pytest.raises(ValueError, match="observability config version 2 is required"): + common_utils.write_relay_configs(plugin_config=plugin_config) + + +def test_provision_relay_dynamic_plugins_uses_lifecycle_and_attaches_config( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import tomli_w + + relay_config_path = tmp_path / "config.toml" + plugin_config_path = tmp_path / "plugins.toml" + relay_config_path.write_text("", encoding="utf-8") + plugin_config_path.write_text( + tomli_w.dumps({"version": 1, "components": []}), + encoding="utf-8", + ) + manifest = tmp_path / "fixture" / "relay-plugin.toml" + manifest.parent.mkdir() + manifest.write_text("[plugin]\nid = 'example.fixture'\n", encoding="utf-8") + calls: list[list[str]] = [] + + def fake_lifecycle(_executable, _config_path, args, **_kwargs): + calls.append(args) + if args[:2] == ["plugins", "add"]: + document = tomllib.loads(plugin_config_path.read_text(encoding="utf-8")) + document.setdefault("plugins", {}).setdefault("dynamic", []).append( + {"manifest": str(manifest), "config": {}} + ) + plugin_config_path.write_text(tomli_w.dumps(document), encoding="utf-8") + + monkeypatch.setattr(common_utils, "_run_relay_lifecycle_command", fake_lifecycle) + + receipt = common_utils.provision_relay_dynamic_plugins( + executable=tmp_path / "nemo-relay", + relay_config_path=relay_config_path, + plugin_config_path=plugin_config_path, + specs=[ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "manifest_ref": str(manifest), + "config": {"threshold": 3}, + } + ], + env={}, + cwd=tmp_path, + ) + + assert calls == [ + ["plugins", "add", str(manifest)], + ["plugins", "enable", "example.fixture"], + ["plugins", "validate", "example.fixture", "--json"], + ] + rendered = tomllib.loads(plugin_config_path.read_text(encoding="utf-8")) + assert rendered["plugins"]["dynamic"][0]["config"] == {"threshold": 3} + assert receipt == [ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "registered": True, + "enabled": True, + "validated": True, + } + ] diff --git a/tests/adapters/test_adapters_common_relay_gateway.py b/tests/adapters/test_adapters_common_relay_gateway.py index 251dd38bd..3d769f925 100644 --- a/tests/adapters/test_adapters_common_relay_gateway.py +++ b/tests/adapters/test_adapters_common_relay_gateway.py @@ -47,7 +47,6 @@ def test_resolve_relay_command_rejects_missing_executable(monkeypatch, tmp_path) @pytest.mark.parametrize( ("output", "expected"), [ - ("nemo-relay 0.5.0\n", 1), ("nemo-relay 0.6.0-alpha.20260714\n", 2), ("nemo-relay 1.0.0\n", 2), ], @@ -67,6 +66,19 @@ def test_relay_cli_observability_version_selects_compatible_contract( ) +def test_relay_cli_observability_version_rejects_relay_05(monkeypatch, tmp_path): + monkeypatch.setattr( + relay_gateway.subprocess, + "run", + MagicMock( + return_value=subprocess.CompletedProcess([], 0, stdout="nemo-relay 0.5.0\n") + ), + ) + + with pytest.raises(relay_gateway.RelayGatewayError, match="0.6 or newer"): + relay_gateway.relay_cli_observability_version(tmp_path / "nemo-relay") + + def test_relay_cli_observability_version_rejects_unparseable_output( monkeypatch, tmp_path ): @@ -157,9 +169,7 @@ def test_start_relay_gateway_stops_failed_process_and_preserves_log( assert log_path.exists() -def test_start_relay_gateway_reports_readiness_and_stop_failures( - monkeypatch, tmp_path -): +def test_start_relay_gateway_reports_readiness_and_stop_failures(monkeypatch, tmp_path): config_path = tmp_path / "config.toml" config_path.write_text("", encoding="utf-8") readiness_error = relay_gateway.RelayGatewayError("not ready") diff --git a/tests/adapters/test_hermes_adapter.py b/tests/adapters/test_hermes_adapter.py index 7fdf5d892..3f16ed5af 100644 --- a/tests/adapters/test_hermes_adapter.py +++ b/tests/adapters/test_hermes_adapter.py @@ -41,14 +41,20 @@ def test_validate_hermes_telemetry_provider_accepts_relay( 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"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): adapter.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}} + payload = { + "telemetry_plan": {"providers": ["relay", "native"], "relay_enabled": True} + } - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): adapter.validate_hermes_telemetry_provider(payload) @@ -132,7 +138,9 @@ def test_default_max_iterations_matches_hermes_library_default(): # multi-step tasks while the trial still reports success. assert adapter.DEFAULT_MAX_ITERATIONS > 1 - hermes_default = inspect.signature(AIAgent.__init__).parameters["max_iterations"].default + hermes_default = ( + inspect.signature(AIAgent.__init__).parameters["max_iterations"].default + ) assert adapter.DEFAULT_MAX_ITERATIONS == hermes_default @@ -143,7 +151,9 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_unset(): "effective_config": { "config": { "harness": {"settings": {}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } } @@ -160,7 +170,9 @@ def test_build_hermes_config_omits_max_turns_when_max_iterations_null(): "effective_config": { "config": { "harness": {"settings": {"max_iterations": None}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } } @@ -179,7 +191,16 @@ def test_hermes_config_variation_matrix_surfaces_supported_capabilities( { "relay": { "config": { - "atof": {"enabled": True, "output_directory": "relay/atof"}, + "version": 2, + "atof": { + "enabled": True, + "sinks": [ + { + "type": "file", + "output_directory": "relay/atof", + } + ], + }, "atif": {"enabled": True, "output_directory": "relay/atif"}, } } @@ -261,8 +282,12 @@ 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"]["sinks"][0]["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" @@ -272,7 +297,9 @@ def test_write_hermes_config_writes_file(tmp_path: Path): "effective_config": { "config": { "harness": {"settings": {}}, - "models": {"default": {"provider": "nvidia", "model": "nvidia/test-model"}}, + "models": { + "default": {"provider": "nvidia", "model": "nvidia/test-model"} + }, } } } @@ -348,7 +375,9 @@ def test_summarize_hermes_config(): async def test_hermes_rejects_native_telemetry(): payload = {"telemetry_plan": {"providers": ["native"], "relay_enabled": False}} - with pytest.raises(ValueError, match="only relay telemetry is supported for Hermes"): + with pytest.raises( + ValueError, match="only relay telemetry is supported for Hermes" + ): await adapter.run_hermes(payload) @@ -368,7 +397,9 @@ async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( mock_ai_agent.session_id = "runtime-fabric-123" mock_ai_agent.model = "test-model" mock_ai_agent.platform = "fabric" - mock_ai_agent.run_conversation.__signature__ = inspect.signature(AIAgent.run_conversation) + mock_ai_agent.run_conversation.__signature__ = inspect.signature( + AIAgent.run_conversation + ) mock_ai_agent.run_conversation.return_value = { "response": "ok", "completed": True, @@ -438,9 +469,13 @@ async def test_fabric_runtime_id_drives_hermes_session_id_and_db_history( output = await adapter.run_hermes(payload) mock_session_db_type.assert_called_once_with() - mock_session_db.resolve_resume_session_id.assert_called_once_with("runtime-fabric-123") + mock_session_db.resolve_resume_session_id.assert_called_once_with( + "runtime-fabric-123" + ) mock_session_db.get_session.assert_called_once_with("runtime-resolved-456") - mock_session_db.get_messages_as_conversation.assert_called_once_with("runtime-resolved-456") + mock_session_db.get_messages_as_conversation.assert_called_once_with( + "runtime-resolved-456" + ) mock_ai_agent_type.assert_called_once_with( base_url=None, api_key="secret", diff --git a/tests/adapters/test_hermes_dual_mode.py b/tests/adapters/test_hermes_dual_mode.py new file mode 100644 index 000000000..800971780 --- /dev/null +++ b/tests/adapters/test_hermes_dual_mode.py @@ -0,0 +1,369 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused tests for Hermes's native and transparent Relay execution strategies.""" + +from __future__ import annotations + +import json +import sys +import tomllib +from pathlib import Path +from types import ModuleType +from types import SimpleNamespace +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest + +import nemo_fabric_adapters.common.utils as common_utils +from nemo_fabric_adapters.hermes import adapter + + +def _payload( + tmp_path: Path, *, launch_mode: str = "native_plugin" +) -> dict[str, object]: + return { + "effective_config": { + "config_root": str(tmp_path), + "config": { + "harness": { + "settings": { + "relay_launch_mode": launch_mode, + "model": "default", + "enabled_toolsets": ["terminal"], + } + }, + "models": { + "default": { + "provider": "test", + "model": "test-model", + "api_key_env": "TEST_API_KEY", + "settings": {"base_url": "https://models.example/v1"}, + } + }, + }, + }, + "runtime_context": { + "runtime_id": "runtime-123", + "environment": {"workspace": str(tmp_path)}, + }, + "request": {"input": "contact alice@example.com"}, + "capability_plan": {"native": {}}, + } + + +def test_relay_launch_mode_defaults_and_rejects_unknown_values(): + assert adapter._relay_launch_mode({}) == adapter.NATIVE_PLUGIN_MODE + assert ( + adapter._relay_launch_mode({"relay_launch_mode": "cli_wrapper"}) + == adapter.CLI_WRAPPER_MODE + ) + with pytest.raises(ValueError, match="unsupported relay_launch_mode"): + adapter._relay_launch_mode({"relay_launch_mode": "sidecar"}) + + +async def test_cli_wrapper_requires_relay_telemetry(tmp_path: Path): + payload = _payload(tmp_path, launch_mode="cli_wrapper") + payload["telemetry_plan"] = {"providers": [], "relay_enabled": False} + + with pytest.raises(RuntimeError, match="requires Relay telemetry"): + await adapter.run_hermes(payload) + + +def test_build_relay_hermes_command_preserves_order_and_forces_custom_provider( + tmp_path: Path, +): + launch = adapter.RelayCliLaunch( + executable=tmp_path / "nemo-relay", + config_path=tmp_path / "config.toml", + plugin_config_path=tmp_path / "plugins.toml", + env={}, + activation_receipt=[], + ) + payload = _payload(tmp_path, launch_mode="cli_wrapper") + settings = common_utils.settings_payload(payload) + model_config = common_utils.selected_model_config(payload) + + command = adapter.build_relay_hermes_command( + launch=launch, + payload=payload, + settings=settings, + model_config=model_config, + user_message="secret prompt", + ) + + assert command == [ + str(tmp_path / "nemo-relay"), + "run", + "--config", + str(tmp_path / "config.toml"), + "--agent", + "hermes", + "--plugin-config-path", + str(tmp_path / "plugins.toml"), + "--", + "chat", + "--quiet", + "--query", + "secret prompt", + "--continue", + "runtime-123", + "--model", + "test-model", + "--provider", + "custom", + "--toolsets", + "terminal", + ] + assert "secret prompt" not in adapter.redact_command(command) + + +def test_fake_cli_subprocess_proves_relay_starts_hermes(tmp_path: Path, monkeypatch): + hermes_log = tmp_path / "hermes-args.json" + hermes = tmp_path / "hermes" + hermes.write_text( + """#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +Path(os.environ[\"FAKE_HERMES_LOG\"]).write_text(json.dumps(sys.argv[1:])) +print(\"fake hermes response\") +""", + encoding="utf-8", + ) + hermes.chmod(0o755) + relay = tmp_path / "nemo-relay" + relay.write_text( + """#!/usr/bin/env python3 +import subprocess, sys, tomllib +if \"--version\" in sys.argv: + print(\"nemo-relay 0.6.0\") + raise SystemExit(0) +args = sys.argv[1:] +config_path = args[args.index(\"--config\") + 1] +with open(config_path, \"rb\") as stream: + config = tomllib.load(stream) +child = [config[\"agents\"][\"hermes\"][\"command\"], *args[args.index(\"--\") + 1:]] +raise SystemExit(subprocess.run(child).returncode) +""", + encoding="utf-8", + ) + relay.chmod(0o755) + relay_wrapper = tmp_path / "relay.json" + relay_wrapper.write_text("{}", encoding="utf-8") + monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(relay_wrapper)) + + payload = _payload(tmp_path, launch_mode="cli_wrapper") + settings = common_utils.settings_payload(payload) + settings["relay_cli_command"] = str(relay) + settings["hermes_command"] = str(hermes) + settings["env"] = {"FAKE_HERMES_LOG": str(hermes_log)} + launch = adapter.prepare_relay_cli_launch( + payload=payload, + settings=settings, + model_config=common_utils.selected_model_config(payload), + hermes_home=tmp_path / "hermes-home", + hermes_config_path=tmp_path / "hermes-home" / "config.yaml", + plugin_config={ + "version": 1, + "components": [ + { + "kind": "pii_redaction", + "enabled": True, + "config": {"version": 1, "mode": "builtin", "mark": True}, + } + ], + }, + dynamic_plugins=[], + ) + command = adapter.build_relay_hermes_command( + launch=launch, + payload=payload, + settings=settings, + model_config=common_utils.selected_model_config(payload), + user_message="hello", + ) + + result, _, _ = adapter.invoke_relay_wrapped_hermes( + command=command, + cwd=tmp_path, + env=launch.env, + ) + + assert result["completed"] is True + assert result["response"] == "fake hermes response" + child_args = json.loads(hermes_log.read_text(encoding="utf-8")) + assert child_args[:4] == ["chat", "--quiet", "--query", "hello"] + assert child_args[child_args.index("--provider") + 1] == "custom" + with launch.config_path.open("rb") as stream: + relay_config = tomllib.load(stream) + assert relay_config["agents"]["hermes"]["command"] == str(hermes) + assert relay_config["upstream"]["openai_base_url"] == "https://models.example/v1" + + +def test_native_plugin_config_layers_dynamic_components_in_order( + tmp_path: Path, monkeypatch +): + relay_wrapper = tmp_path / "relay.json" + relay_wrapper.write_text("{}", encoding="utf-8") + monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(relay_wrapper)) + base = { + "version": 1, + "components": [{"kind": "observability", "config": {"version": 2}}], + } + + path = adapter.write_native_relay_plugin_config( + base, + [ + { + "plugin_id": "example.first", + "kind": "rust_dynamic", + "manifest_ref": "first.toml", + "config": {"sequence": 1}, + }, + { + "plugin_id": "example.second", + "kind": "worker", + "manifest_ref": "second.toml", + "config": {"sequence": 2}, + }, + ], + ) + + with path.open("rb") as stream: + document = tomllib.load(stream) + assert [component["kind"] for component in document["components"]] == [ + "observability", + "example.first", + "example.second", + ] + assert document["components"][1]["config"] == {"sequence": 1} + assert document["components"][2]["config"] == {"sequence": 2} + assert base["components"] == [ + {"kind": "observability", "config": {"version": 2}} + ] + + +def test_native_plugin_environment_is_invocation_scoped(tmp_path: Path, monkeypatch): + name = "HERMES_NEMO_RELAY_PLUGINS_TOML" + monkeypatch.setenv(name, "parent.toml") + + with adapter.native_relay_plugin_environment(tmp_path / "invocation.toml"): + assert adapter.os.environ[name] == str(tmp_path / "invocation.toml") + + assert adapter.os.environ[name] == "parent.toml" + + +class _Activation: + def __init__(self) -> None: + self.report = {"diagnostics": []} + self.entered = False + self.closed = False + + async def __aenter__(self): + self.entered = True + return self + + async def __aexit__(self, *_args): + self.closed = True + + +async def test_native_static_configuration_keeps_existing_plugin_context( + tmp_path: Path, + monkeypatch, +): + relay_wrapper = tmp_path / "relay.json" + relay_wrapper.write_text( + json.dumps({"relay": {"config": {"version": 1, "components": []}}}), + encoding="utf-8", + ) + monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(relay_wrapper)) + monkeypatch.setenv("TEST_API_KEY", "not-a-real-secret") + payload = _payload(tmp_path) + payload["telemetry_plan"] = {"providers": ["relay"], "relay_enabled": True} + activation = _Activation() + plugin_context = MagicMock(return_value=activation) + fake_relay = ModuleType("nemo_relay") + fake_relay.plugin = SimpleNamespace(plugin=plugin_context) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "nemo_relay", fake_relay) + monkeypatch.setattr( + common_utils, "relay_api_plugin_config", lambda _config: "base-config" + ) + monkeypatch.setattr( + adapter, + "_invoke_hermes", + MagicMock( + return_value=( + {"response": "ok", "completed": True, "failed": False, "messages": []}, + [], + [], + "", + ) + ), + ) + + output = await adapter.run_hermes(payload) + + plugin_context.assert_called_once_with("base-config") + assert activation.entered is True + assert activation.closed is True + assert output["relay_launch_mode"] == "native_plugin" + + +async def test_native_dynamic_plugins_use_owned_activation_for_complete_call( + tmp_path: Path, + monkeypatch, +): + relay_wrapper = tmp_path / "relay.json" + relay_wrapper.write_text( + json.dumps( + { + "relay": { + "config": {"version": 1, "components": []}, + "dynamic_plugins": [ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "manifest_ref": "fixture/relay-plugin.toml", + "config": {"mode": "test"}, + } + ], + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("FABRIC_RELAY_CONFIG_PATH", str(relay_wrapper)) + monkeypatch.setenv("TEST_API_KEY", "not-a-real-secret") + payload = _payload(tmp_path) + payload["telemetry_plan"] = {"providers": ["relay"], "relay_enabled": True} + activation = _Activation() + initialize = AsyncMock(return_value=activation) + fake_plugin = SimpleNamespace(initialize_with_dynamic_plugins=initialize) + fake_relay = ModuleType("nemo_relay") + fake_relay.plugin = fake_plugin # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "nemo_relay", fake_relay) + monkeypatch.setattr( + common_utils, "relay_api_plugin_config", lambda _config: "base-config" + ) + monkeypatch.setattr( + common_utils, "relay_api_dynamic_plugins", lambda _specs: ["dynamic-spec"] + ) + invoke = MagicMock( + return_value=( + {"response": "ok", "completed": True, "failed": False, "messages": []}, + [], + [], + "", + ) + ) + monkeypatch.setattr(adapter, "_invoke_hermes", invoke) + + output = await adapter.run_hermes(payload) + + initialize.assert_awaited_once_with("base-config", ["dynamic-spec"]) + assert activation.entered is True + assert activation.closed is True + invoke.assert_called_once() + assert output["relay_runtime"]["emitter"] == "hermes.observability/nemo_relay" + assert output["relay_runtime"]["activation_report"] == {"diagnostics": []} diff --git a/tests/e2e/test_hermes_e2e.py b/tests/e2e/test_hermes_e2e.py index 118ba7d4f..6f0fa0d60 100644 --- a/tests/e2e/test_hermes_e2e.py +++ b/tests/e2e/test_hermes_e2e.py @@ -62,7 +62,6 @@ async def run_hermes_with_relay( ).resolve() self.relay_artifacts = self.output["relay_artifacts"] - async def test_artifacts(self): assert self.result["status"] == "succeeded" assert self.result["adapter_kind"] == self.adapter_kind @@ -79,9 +78,9 @@ async def test_artifacts(self): assert output["base_url"] == f"{self.api_server}/v1" assert output["error"] is None assert output["relay_runtime"]["enabled"] is True - assert output["relay_runtime"]["emitter"] == "hermes.observability/nemo_relay" + assert output["relay_runtime"]["emitter"] == "hermes.observability/nemo_relay" assert output["failed"] is False - + assert "echo user_count=" in output["response"] hermes_home = Path(output["hermes_home"]).resolve() @@ -105,8 +104,7 @@ async def test_artifacts(self): assert self.artifact_root.is_dir() artifact_by_name = { - artifact["name"]: artifact - for artifact in self.artifacts["artifacts"] + artifact["name"]: artifact for artifact in self.artifacts["artifacts"] } assert "relay_config" in artifact_by_name assert "stdout" in artifact_by_name @@ -114,12 +112,12 @@ async def test_artifacts(self): relay_config_path = Path(artifact_by_name["relay_config"]["path"]).resolve() assert relay_config_path.is_file() assert relay_config_path.is_relative_to(self.artifact_root) - + relay_config = json.loads(relay_config_path.read_text(encoding="utf-8")) assert relay_config["schema_version"] == "fabric.relay/v1alpha1" assert relay_config["relay"]["enabled"] is True assert relay_config["fabric"]["profiles"] == [] - + async def test_atof_artifacts(self): kinds = {artifact["kind"] for artifact in self.relay_artifacts} assert "atof" in kinds @@ -131,13 +129,10 @@ async def test_atof_artifacts(self): ] assert atof_paths assert all(path.exists() for path in atof_paths) - assert all( - path.is_relative_to(self.relay_artifact_root) for path in atof_paths - ) + assert all(path.is_relative_to(self.relay_artifact_root) for path in atof_paths) atof_records = [ - json.loads(line) - for line in atof_paths[0].read_text().strip().splitlines() + json.loads(line) for line in atof_paths[0].read_text().strip().splitlines() ] expected_atof_fields = { "atof_version", @@ -155,18 +150,20 @@ async def test_atof_artifacts(self): actual_atof_fields = set().union(*(record.keys() for record in atof_records)) assert actual_atof_fields.issuperset(expected_atof_fields) - assert len(atof_records) == 7 - + assert atof_records + names = [record.get("name") for record in atof_records] + assert names.count("hermes.turn.start") == 1 + assert names.count("hermes.session.end") == 1 + assert all( record["metadata"]["model"] == "nvidia/nemotron-3-nano-30b-a3b" and record["metadata"]["platform"] == self.atof_platform for record in atof_records ) - + assert atof_records[-2]["name"] == "hermes.session.end" assert atof_records[-1]["scope_category"] == "end" - async def test_atif_artifacts(self): kinds = {artifact["kind"] for artifact in self.relay_artifacts} assert "atif" in kinds @@ -178,20 +175,20 @@ async def test_atif_artifacts(self): ] assert atif_paths assert all(path.exists() for path in atif_paths) - assert all( - path.is_relative_to(self.relay_artifact_root) for path in atif_paths - ) + assert all(path.is_relative_to(self.relay_artifact_root) for path in atif_paths) trajectory = json.loads(atif_paths[0].read_text()) assert trajectory["agent"]["name"] in {"code-review-agent", "Hermes Agent"} steps = trajectory["steps"] - assert len(steps) == 5 - - first_step = steps[0] - assert first_step["message"] == "hermes.turn.start" - assert first_step["extra"]["event_payload"]["is_first_turn"] is True - - last_step = steps[-1] - assert last_step["message"] == "hermes.session.end" - assert last_step["extra"]["invocation"]["framework"] == "nemo_relay" - assert last_step["extra"]["invocation"]["status"] == "completed" + user_steps = [step for step in steps if step["source"] == "user"] + agent_steps = [step for step in steps if step["source"] == "agent"] + assert len(user_steps) == 1 + assert len(agent_steps) == 1 + assert user_steps[0]["message"] == "Reply with exactly: relay ok" + assert user_steps[0]["step_id"] < agent_steps[0]["step_id"] + assert agent_steps[0]["model_name"] == "nvidia/nemotron-3-nano-30b-a3b" + assert agent_steps[0]["extra"]["llm_response"]["model"] == ( + "nvidia/nemotron-3-nano-30b-a3b" + ) + assert agent_steps[0]["extra"]["invocation"]["framework"] == "nemo_relay" + assert agent_steps[0]["extra"]["invocation"]["status"] == "completed" diff --git a/tests/fixtures/file-config-agent/profiles/mcp-github.yaml b/tests/fixtures/file-config-agent/profiles/mcp-github.yaml index b45bbb80b..8c1c65832 100644 --- a/tests/fixtures/file-config-agent/profiles/mcp-github.yaml +++ b/tests/fixtures/file-config-agent/profiles/mcp-github.yaml @@ -25,6 +25,8 @@ relay: filename_template: "trajectory-{session_id}.atif.json" atof: enabled: true - output_directory: ./artifacts/mcp-github - filename: events.atof.jsonl - mode: overwrite + sinks: + - type: file + output_directory: ./artifacts/mcp-github + filename: events.atof.jsonl + mode: overwrite diff --git a/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml b/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml index 0191fcb9a..1e669c8ec 100644 --- a/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml +++ b/tests/fixtures/file-config-agent/profiles/relay-openinference.yaml @@ -20,9 +20,11 @@ relay: agent_version: fabric-poc atof: enabled: true - output_directory: ./artifacts/relay-openinference - filename: events.atof.jsonl - mode: overwrite + sinks: + - type: file + output_directory: ./artifacts/relay-openinference + filename: events.atof.jsonl + mode: overwrite openinference: enabled: true transport: http_binary diff --git a/tests/fixtures/file-config-agent/profiles/relay.yaml b/tests/fixtures/file-config-agent/profiles/relay.yaml index 9550bbd80..9720624e0 100644 --- a/tests/fixtures/file-config-agent/profiles/relay.yaml +++ b/tests/fixtures/file-config-agent/profiles/relay.yaml @@ -20,6 +20,8 @@ relay: agent_version: fabric-poc atof: enabled: true - output_directory: ./artifacts/relay - filename: events.atof.jsonl - mode: overwrite + sinks: + - type: file + output_directory: ./artifacts/relay + filename: events.atof.jsonl + mode: overwrite diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index f66f93692..7f329c29d 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -32,8 +32,10 @@ from nemo_fabric import MetadataConfig from nemo_fabric import RelayAtifConfig from nemo_fabric import RelayAtofConfig +from nemo_fabric import RelayAtofFileSinkConfig from nemo_fabric import RelayComponentConfig from nemo_fabric import RelayConfigPolicy +from nemo_fabric import RelayDynamicPluginConfig from nemo_fabric import RelayObservabilityConfig from nemo_fabric import RunOutput from nemo_fabric import RunPlan @@ -168,6 +170,7 @@ def test_typed_config_authoring_helpers_emit_schema_shape(): "project": "fabric-tests", "output_dir": "./artifacts/relay", "components": [], + "dynamic_plugins": [], } config.remove_mcp_server("github").remove_mcp_server("missing") @@ -221,9 +224,13 @@ def test_fabric_config_authors_first_class_relay_observability(): observability=RelayObservabilityConfig( atof=RelayAtofConfig( enabled=True, - output_directory="./artifacts/relay", - filename="events.atof.jsonl", - mode="overwrite", + sinks=[ + RelayAtofFileSinkConfig( + output_directory="./artifacts/relay", + filename="events.atof.jsonl", + mode="overwrite", + ) + ], ), atif=RelayAtifConfig( enabled=True, @@ -235,6 +242,14 @@ def test_fabric_config_authors_first_class_relay_observability(): components=[ RelayComponentConfig(kind="switchyard", config={"route": "canary"}), ], + dynamic_plugins=[ + RelayDynamicPluginConfig( + plugin_id="example.fixture", + kind="rust_dynamic", + manifest_ref="./plugins/example/relay-plugin.toml", + config={"mode": "test"}, + ) + ], policy=RelayConfigPolicy(unknown_component="error"), ) @@ -244,12 +259,17 @@ def test_fabric_config_authors_first_class_relay_observability(): assert config.to_mapping()["relay"] == { "output_dir": "./artifacts/relay", "observability": { - "version": 1, + "version": 2, "atof": { "enabled": True, - "output_directory": "./artifacts/relay", - "filename": "events.atof.jsonl", - "mode": "overwrite", + "sinks": [ + { + "type": "file", + "output_directory": "./artifacts/relay", + "filename": "events.atof.jsonl", + "mode": "overwrite", + } + ], }, "atif": { "enabled": True, @@ -266,6 +286,14 @@ def test_fabric_config_authors_first_class_relay_observability(): "config": {"route": "canary"}, }, ], + "dynamic_plugins": [ + { + "plugin_id": "example.fixture", + "kind": "rust_dynamic", + "manifest_ref": "./plugins/example/relay-plugin.toml", + "config": {"mode": "test"}, + } + ], "policy": { "unknown_component": "error", "unknown_field": "warn", @@ -344,6 +372,7 @@ def test_config_emits_schema_shape_and_validates(): "project": "fabric-tests", "output_dir": "./artifacts/relay", "components": [], + "dynamic_plugins": [], } assert config.extra_fields == {"future_top_level": {"enabled": True}} @@ -367,7 +396,12 @@ def test_agent_model_tracks_rust_schema_top_level_fields(): assert set(pydantic_schema["properties"]).issuperset(schema["properties"]) assert set(pydantic_schema["required"]) == {"metadata", "harness"} - assert set(schema["required"]) == {"schema_version", "metadata", "harness", "runtime"} + assert set(schema["required"]) == { + "schema_version", + "metadata", + "harness", + "runtime", + } def test_environment_model_defines_extension_field_ownership(): @@ -660,7 +694,9 @@ 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( @@ -670,14 +706,18 @@ 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) @@ -744,7 +784,9 @@ 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, @@ -806,7 +848,9 @@ 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} @@ -888,7 +932,9 @@ def test_run_output_exposes_response_and_preserves_extensions(): def test_run_result_wraps_object_output_as_run_output(): - result = RunResult.from_mapping(_run_result(output={"response": "hello", "usage": {"tokens": 1}})) + result = RunResult.from_mapping( + _run_result(output={"response": "hello", "usage": {"tokens": 1}}) + ) assert isinstance(result.output, RunOutput) assert result.output.response == "hello" @@ -924,7 +970,9 @@ def test_run_output_preserves_non_string_response_without_raising(): def test_run_result_preserves_structured_response_from_core_valid_output(): - result = RunResult.from_mapping(_run_result(output={"response": {"text": "hello"}, "usage": {"tokens": 1}})) + result = RunResult.from_mapping( + _run_result(output={"response": {"text": "hello"}, "usage": {"tokens": 1}}) + ) assert isinstance(result.output, RunOutput) assert result.output.response == {"text": "hello"} @@ -1173,7 +1221,9 @@ def model_dump(self, *, mode: str, exclude_none: bool) -> dict[str, Any]: ) client.plan(config, profiles=[FabricProfileConfig(name="typed")]) - assert native.config_profile_calls == [[{"schema_version": "fabric.profile/v1alpha1", "name": "typed"}]] + assert native.config_profile_calls == [ + [{"schema_version": "fabric.profile/v1alpha1", "name": "typed"}] + ] def test_typed_config_profiles_require_profile_models(): From 2ba7e758718240cbabffa191c4a5c0207da0e21e Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 15 Jul 2026 15:03:14 -0600 Subject: [PATCH 2/4] feat(relay): accept canonical plugin config files Signed-off-by: Bryan Bednarski --- .../src/nemo_fabric_adapters/common/utils.py | 89 ++++++++++++++++--- adapters/hermes/README.md | 11 +++ crates/fabric-core/src/config.rs | 79 ++++++++++++++++ crates/fabric-core/src/runtime.rs | 4 + docs/sdk/python.mdx | 15 ++++ python/src/nemo_fabric/models.py | 4 + python/src/nemo_fabric/types.py | 3 + schemas/adapter-invocation.schema.json | 14 +++ schemas/agent.schema.json | 7 ++ schemas/effective-config.schema.json | 7 ++ schemas/run-plan.schema.json | 14 +++ tests/adapters/test_adapaters_common_utils.py | 61 +++++++++++++ 12 files changed, 298 insertions(+), 10 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 30d1d1db0..1710eeb1d 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -222,16 +222,49 @@ def dump_yaml(value: dict[str, Any]) -> str: return json.dumps(value, indent=2, sort_keys=False) + "\n" -def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: +def _relay_runtime_document() -> dict[str, Any]: config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") if not config_path: raise RuntimeError("FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled") with Path(config_path).open(encoding="utf-8") as stream: - wrapper = json.load(stream) + document = json.load(stream) + if not isinstance(document, dict): + raise ValueError("Fabric Relay runtime config must be an object") + return document + + +def _external_relay_plugin_document( + payload: dict[str, Any], relay: dict[str, Any] +) -> tuple[Path, dict[str, Any]] | None: + configured = relay.get("plugin_config_path") + if configured is None: + return None + path = Path(str(configured)) + if not path.is_absolute(): + path = Path(config_root(payload)).resolve() / path + path = path.resolve() + if not path.is_file(): + raise RuntimeError(f"Relay plugin config file was not found: {path}") + with path.open("rb") as stream: + document = tomllib.load(stream) + return path, document + +def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: + wrapper = _relay_runtime_document() relay = wrapper.get("relay", {}) - plugin_config = relay.get("config") or {} + external = _external_relay_plugin_document(payload, relay) + if external is not None: + _, document = external + plugin_config = { + key: copy.deepcopy(value) + for key, value in document.items() + if key != "plugins" + } + else: + plugin_config = copy.deepcopy(relay.get("config") or {}) + if "components" not in plugin_config: plugin_config = { "version": 1, @@ -252,14 +285,50 @@ def load_relay_plugin_config(payload: dict[str, Any]) -> dict[str, Any]: def load_relay_dynamic_plugins(payload: dict[str, Any]) -> list[dict[str, Any]]: """Load ordered dynamic plugin specs and resolve invocation-relative paths.""" - config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") - if not config_path: - raise RuntimeError("FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled") - - with Path(config_path).open(encoding="utf-8") as stream: - wrapper = json.load(stream) + wrapper = _relay_runtime_document() + relay = wrapper.get("relay") or {} + external = _external_relay_plugin_document(payload, relay) + if external is not None: + plugin_config_path, document = external + entries = ((document.get("plugins") or {}).get("dynamic") or []) + if not isinstance(entries, list): + raise ValueError("plugins.dynamic must be an array of tables") + specs: list[dict[str, Any]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise ValueError(f"plugins.dynamic[{index}] must be a table") + manifest_value = entry.get("manifest") + if not isinstance(manifest_value, str) or not manifest_value: + raise ValueError(f"plugins.dynamic[{index}].manifest must be a string") + manifest_path = Path(manifest_value) + if not manifest_path.is_absolute(): + manifest_path = plugin_config_path.parent / manifest_path + manifest_path = manifest_path.resolve() + if not manifest_path.is_file(): + raise RuntimeError(f"Relay plugin manifest was not found: {manifest_path}") + with manifest_path.open("rb") as stream: + manifest = tomllib.load(stream) + identity = manifest.get("plugin") or {} + plugin_id = identity.get("id") + kind = identity.get("kind") + if not isinstance(plugin_id, str) or kind not in {"rust_dynamic", "worker"}: + raise ValueError( + f"plugins.dynamic[{index}] manifest has an invalid plugin identity" + ) + config = entry.get("config") or {} + if not isinstance(config, dict): + raise ValueError(f"plugins.dynamic[{index}].config must be a table") + specs.append( + { + "plugin_id": plugin_id, + "kind": kind, + "manifest_ref": str(manifest_path), + "config": copy.deepcopy(config), + } + ) + return specs - specs = (wrapper.get("relay") or {}).get("dynamic_plugins") or [] + specs = relay.get("dynamic_plugins") or [] if not isinstance(specs, list): raise ValueError("relay.dynamic_plugins must be a list") root = Path(config_root(payload)).resolve() diff --git a/adapters/hermes/README.md b/adapters/hermes/README.md index 7ee8aa19a..91ae9710c 100644 --- a/adapters/hermes/README.md +++ b/adapters/hermes/README.md @@ -21,6 +21,17 @@ strategies selected by `harness.settings.relay_launch_mode`: executable with `harness.settings.relay_cli_command`; it defaults to `nemo-relay`. +For a Relay configuration that should move unchanged from an evaluation into a +deployment, set `relay.plugin_config_path` to one canonical Relay +`plugins.toml`. Fabric loads its built-in components and standard +`[[plugins.dynamic]]` declarations for either launch strategy. Do not combine +that path with Fabric's inline Relay observability, component, dynamic-plugin, +or policy fields. Relay's separate `config.toml` remains invocation-owned +agent/gateway launch metadata in CLI-wrapper mode. CLI-wrapper mode can +provision lifecycle-managed Python worker environments from these declarations; +native mode can directly activate native dynamic plugins, while worker plugins +still require an existing Relay-managed `environment_ref`. + Fabric invokes the adapter module with `python -m` through the core runtime lifecycle. The module entry point and the descriptor's callable route use the same `run(payload: dict) -> dict` implementation. diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index def66dd3d..a8593c3de 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -656,6 +656,9 @@ pub struct TelemetryProviderConfig { /// NeMo Relay integration configuration. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RelayConfig { + /// Optional path to a canonical Relay `plugins.toml` document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_config_path: Option, /// Optional project name for Relay backends. #[serde(default, skip_serializing_if = "Option::is_none")] pub project: Option, @@ -1838,6 +1841,9 @@ fn resolve_telemetry_plan( relay_output_dir: relay_enabled .then(|| relay.and_then(|relay| relay.output_dir.clone())) .flatten(), + relay_plugin_config_path: relay_enabled + .then(|| relay.and_then(|relay| relay.plugin_config_path.clone())) + .flatten(), relay_config: relay_enabled .then(|| resolve_relay_plugin_config(relay)) .flatten(), @@ -1854,6 +1860,16 @@ fn resolve_telemetry_plan( } fn validate_relay_config(relay: &RelayConfig) -> Result<()> { + if relay.plugin_config_path.is_some() + && (relay.observability.is_some() + || !relay.components.is_empty() + || !relay.dynamic_plugins.is_empty() + || relay.policy.is_some()) + { + return Err(FabricError::InvalidRelayConfig { + message: "plugin_config_path cannot be combined with inline Relay observability, components, dynamic_plugins, or policy".to_string(), + }); + } let Some(observability) = relay.observability.as_ref() else { return Ok(()); }; @@ -2140,6 +2156,9 @@ pub struct TelemetryPlan { /// Relay output directory, when configured. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_output_dir: Option, + /// Canonical Relay `plugins.toml` path, when supplied by the agent config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_plugin_config_path: Option, /// Relay pass-through config. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_config: Option, @@ -2410,6 +2429,66 @@ relay: ); } + #[test] + fn relay_telemetry_can_reference_one_canonical_plugins_toml() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes +runtime: +telemetry: + providers: + relay: {} +relay: + plugin_config_path: ./relay/plugins.toml +"#, + ) + .expect("config with external Relay plugin config"); + + let plan = resolve_telemetry_plan(&config, None) + .expect("resolve telemetry plan") + .expect("telemetry plan"); + + assert_eq!( + plan.relay_plugin_config_path, + Some(PathBuf::from("./relay/plugins.toml")) + ); + assert_eq!(plan.relay_config, None); + assert!(plan.relay_dynamic_plugins.is_empty()); + } + + #[test] + fn relay_telemetry_rejects_external_and_inline_plugin_config() { + let config: FabricConfig = serde_yaml::from_str( + r#" +schema_version: fabric.agent/v1alpha1 +metadata: + name: demo +harness: + adapter_id: nvidia.fabric.hermes +runtime: +telemetry: + providers: + relay: {} +relay: + plugin_config_path: ./plugins.toml + components: + - kind: observability +"#, + ) + .expect("conflicting Relay config parses for a clear error"); + + let error = resolve_telemetry_plan(&config, None).expect_err("conflict must fail"); + assert!( + error + .to_string() + .contains("plugin_config_path cannot be combined") + ); + } + #[test] fn relay_telemetry_rejects_legacy_observability_contract() { let config: FabricConfig = serde_yaml::from_str( diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 6fb016e85..0dc97c564 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -1884,6 +1884,10 @@ fn prepare_relay_runtime_config( .relay_output_dir .as_ref() .map(|path| path.to_string_lossy().into_owned()), + "plugin_config_path": telemetry + .relay_plugin_config_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), "config": telemetry .relay_config .clone() diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index 0f635c72f..77f0bd556 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -190,6 +190,21 @@ relay_config.enable_relay( ) ``` +Alternatively, point Fabric at one canonical Relay `plugins.toml` when the same +configuration must be promoted from evaluation to deployment: + +```python +relay_config = config.model_copy(deep=True) +relay_config.enable_relay(plugin_config_path="./relay/plugins.toml") +``` + +The external file may contain built-in components and standard +`[[plugins.dynamic]]` declarations. It is mutually exclusive with Fabric's +inline Relay observability, component, dynamic-plugin, and policy fields. The +CLI-wrapper strategy can provision lifecycle-managed Python workers from those +declarations. Native execution requires an existing Relay-managed +`environment_ref` for worker plugins. + The repository's [code-review example](https://github.com/NVIDIA/NeMo-Fabric/tree/main/examples/code_review_agent) uses this pattern for complete Hermes, Codex CLI, Deep Agents, diff --git a/python/src/nemo_fabric/models.py b/python/src/nemo_fabric/models.py index c0756e64c..7872641c3 100644 --- a/python/src/nemo_fabric/models.py +++ b/python/src/nemo_fabric/models.py @@ -387,6 +387,7 @@ class RelayDynamicPluginConfig(FabricBaseModel): class RelayConfig(FabricBaseModel): """First-class NeMo Relay integration configuration.""" + plugin_config_path: str | Path | None = None project: str | None = None output_dir: str | Path | None = None observability: RelayObservabilityConfig | dict[str, Any] | None = None @@ -543,6 +544,7 @@ def block_tools(self, *tools: str) -> Self: def enable_relay( self, *, + plugin_config_path: str | Path | None = None, project: str | None = None, output_dir: str | Path | None = None, observability: RelayObservabilityConfig | Mapping[str, Any] | None = None, @@ -562,6 +564,8 @@ def enable_relay( relay = self.relay.model_copy(deep=True) else: relay = RelayConfig.from_mapping(self.relay) + if plugin_config_path is not None: + relay.plugin_config_path = plugin_config_path if project is not None: relay.project = project if output_dir is not None: diff --git a/python/src/nemo_fabric/types.py b/python/src/nemo_fabric/types.py index 1298b71af..c56e0b057 100644 --- a/python/src/nemo_fabric/types.py +++ b/python/src/nemo_fabric/types.py @@ -751,6 +751,7 @@ def block_tools(self, *tools: str) -> _ResolvedFabricConfig: def enable_relay( self, *, + plugin_config_path: str | Path | None = None, project: str | None = None, output_dir: str | Path | None = None, observability: Mapping[str, Any] | None = None, @@ -761,6 +762,8 @@ def enable_relay( self.telemetry.enable_relay() relay = dict(self.get("relay") or {}) + if plugin_config_path is not None: + relay["plugin_config_path"] = str(plugin_config_path) if project is not None: relay["project"] = project if output_dir is not None: diff --git a/schemas/adapter-invocation.schema.json b/schemas/adapter-invocation.schema.json index 86ebfcee0..e8f687e9e 100644 --- a/schemas/adapter-invocation.schema.json +++ b/schemas/adapter-invocation.schema.json @@ -1060,6 +1060,13 @@ "null" ] }, + "plugin_config_path": { + "description": "Optional path to a canonical Relay `plugins.toml` document.", + "type": [ + "string", + "null" + ] + }, "policy": { "anyOf": [ { @@ -1621,6 +1628,13 @@ "null" ] }, + "relay_plugin_config_path": { + "description": "Canonical Relay `plugins.toml` path, when supplied by the agent config.", + "type": [ + "string", + "null" + ] + }, "relay_project": { "description": "Relay project, when configured.", "type": [ diff --git a/schemas/agent.schema.json b/schemas/agent.schema.json index c2195844d..fcd401807 100644 --- a/schemas/agent.schema.json +++ b/schemas/agent.schema.json @@ -637,6 +637,13 @@ "null" ] }, + "plugin_config_path": { + "description": "Optional path to a canonical Relay `plugins.toml` document.", + "type": [ + "string", + "null" + ] + }, "policy": { "anyOf": [ { diff --git a/schemas/effective-config.schema.json b/schemas/effective-config.schema.json index 0a6452f74..0d453bf0e 100644 --- a/schemas/effective-config.schema.json +++ b/schemas/effective-config.schema.json @@ -743,6 +743,13 @@ "null" ] }, + "plugin_config_path": { + "description": "Optional path to a canonical Relay `plugins.toml` document.", + "type": [ + "string", + "null" + ] + }, "policy": { "anyOf": [ { diff --git a/schemas/run-plan.schema.json b/schemas/run-plan.schema.json index 1ef90cbd7..9c1fce3f0 100644 --- a/schemas/run-plan.schema.json +++ b/schemas/run-plan.schema.json @@ -1210,6 +1210,13 @@ "null" ] }, + "plugin_config_path": { + "description": "Optional path to a canonical Relay `plugins.toml` document.", + "type": [ + "string", + "null" + ] + }, "policy": { "anyOf": [ { @@ -1729,6 +1736,13 @@ "null" ] }, + "relay_plugin_config_path": { + "description": "Canonical Relay `plugins.toml` path, when supplied by the agent config.", + "type": [ + "string", + "null" + ] + }, "relay_project": { "description": "Relay project, when configured.", "type": [ diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index fa8fe8975..27b50b609 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -375,6 +375,67 @@ def test_load_relay_plugin_config_wraps_and_normalizes_bare_observability_config ] +def test_loads_external_plugins_toml_as_components_and_dynamic_specs(tmp_path: Path): + plugin_root = tmp_path / "relay" + plugin_root.mkdir() + manifest = plugin_root / "relay-plugin.toml" + manifest.write_text( + 'manifest_version = 1\n[plugin]\nid = "example.native"\nkind = "rust_dynamic"\n', + encoding="utf-8", + ) + plugins_toml = plugin_root / "plugins.toml" + plugins_toml.write_text( + """version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 2 + +[components.config.atof] +enabled = true + +[[components.config.atof.sinks]] +type = "file" +output_directory = "artifacts" + +[[plugins.dynamic]] +manifest = "relay-plugin.toml" +config = { mode = "test" } +""", + encoding="utf-8", + ) + runtime_config = tmp_path / "relay.json" + runtime_config.write_text( + json.dumps({"relay": {"plugin_config_path": "relay/plugins.toml"}}), + encoding="utf-8", + ) + os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(runtime_config) + payload = { + "effective_config": {"config_root": str(tmp_path)}, + "runtime_context": {"runtime_id": "runtime-external"}, + } + + plugin_config = common_utils.load_relay_plugin_config(payload) + specs = common_utils.load_relay_dynamic_plugins(payload) + + assert "plugins" not in plugin_config + assert plugin_config["components"][0]["kind"] == "observability" + assert plugin_config["components"][0]["config"]["atof"]["sinks"][0][ + "output_directory" + ] == str(tmp_path / "artifacts" / "runtime-external") + assert specs == [ + { + "plugin_id": "example.native", + "kind": "rust_dynamic", + "manifest_ref": str(manifest), + "config": {"mode": "test"}, + } + ] + + def test_collect_relay_artifacts(tmp_path: Path): atof_dir = tmp_path / "atof" atif_dir = tmp_path / "atif" From 76a0ed00349f4aec3a858f3764bc77e3359dee15 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 15 Jul 2026 17:34:29 -0600 Subject: [PATCH 3/4] fix(hermes): preserve max tokens across launch modes Signed-off-by: Bryan Bednarski --- .../hermes/src/nemo_fabric_adapters/hermes/adapter.py | 2 ++ tests/adapters/test_hermes_dual_mode.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 604d820a1..0ee8a975a 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -82,6 +82,8 @@ def build_hermes_config( "provider": provider, "default": model_name, "base_url": base_url, + "max_tokens": settings.get("max_tokens") + or model_config.get("max_tokens"), } ), "agent": common_utils.without_none( diff --git a/tests/adapters/test_hermes_dual_mode.py b/tests/adapters/test_hermes_dual_mode.py index 800971780..4a260fa56 100644 --- a/tests/adapters/test_hermes_dual_mode.py +++ b/tests/adapters/test_hermes_dual_mode.py @@ -63,6 +63,16 @@ def test_relay_launch_mode_defaults_and_rejects_unknown_values(): adapter._relay_launch_mode({"relay_launch_mode": "sidecar"}) +def test_explicit_max_tokens_is_shared_by_native_and_cli_modes(tmp_path: Path): + payload = _payload(tmp_path) + settings = common_utils.settings_payload(payload) + settings["max_tokens"] = 4096 + + config = adapter.build_hermes_config(payload, relay_enabled=True) + + assert config["model"]["max_tokens"] == 4096 + + async def test_cli_wrapper_requires_relay_telemetry(tmp_path: Path): payload = _payload(tmp_path, launch_mode="cli_wrapper") payload["telemetry_plan"] = {"providers": [], "relay_enabled": False} From 678988c2cfd1bf77a225d09d272f3811add43704 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Thu, 16 Jul 2026 09:14:17 -0600 Subject: [PATCH 4/4] fix(hermes): isolate relay CLI plugin state --- .../src/nemo_fabric_adapters/common/utils.py | 18 ++++++++++-------- .../src/nemo_fabric_adapters/hermes/adapter.py | 12 +++++++----- tests/adapters/test_adapaters_common_utils.py | 16 ++++++++++++++++ tests/adapters/test_hermes_dual_mode.py | 3 +++ 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/adapters/common/src/nemo_fabric_adapters/common/utils.py b/adapters/common/src/nemo_fabric_adapters/common/utils.py index 1710eeb1d..dade06c03 100644 --- a/adapters/common/src/nemo_fabric_adapters/common/utils.py +++ b/adapters/common/src/nemo_fabric_adapters/common/utils.py @@ -664,18 +664,20 @@ def write_relay_configs( relay_config: dict[str, Any] | None = None, plugin_config: dict[str, Any] | None = None, observability_version: int = 2, + config_directory: Path | None = None, ) -> tuple[Path | None, Path | None]: try: import tomli_w - config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") - if not config_path: - raise RuntimeError( - "FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled" - ) - - config_path = Path(config_path) - config_dir = config_path.parent / "relay-config" + if config_directory is None: + config_path = os.environ.get("FABRIC_RELAY_CONFIG_PATH") + if not config_path: + raise RuntimeError( + "FABRIC_RELAY_CONFIG_PATH is required when Relay is enabled" + ) + config_dir = Path(config_path).parent / "relay-config" + else: + config_dir = Path(config_directory) config_dir.mkdir(parents=True, exist_ok=True) relay_config_path = None plugin_config_path = None diff --git a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py index 0ee8a975a..e53a319ee 100755 --- a/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py +++ b/adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py @@ -296,10 +296,17 @@ def prepare_relay_cli_launch( if base_url: relay_config["upstream"] = {"openai_base_url": base_url} + invocation_id = str( + common_utils.runtime_context(payload).get("invocation_id") + or common_utils.runtime_id(payload) + ) + isolation_root = hermes_home / "relay-cli" / invocation_id + isolation_root.mkdir(parents=True, exist_ok=True) relay_config_path, plugin_config_path = common_utils.write_relay_configs( relay_config=relay_config, plugin_config=plugin_config, observability_version=2, + config_directory=isolation_root / "relay-config", ) if relay_config_path is None or plugin_config_path is None: raise RuntimeError("Relay CLI wrapper configuration was not written") @@ -308,11 +315,6 @@ def prepare_relay_cli_launch( env.update( {str(key): str(value) for key, value in (settings.get("env") or {}).items()} ) - invocation_id = str( - common_utils.runtime_context(payload).get("invocation_id") - or common_utils.runtime_id(payload) - ) - isolation_root = hermes_home / "relay-cli" / invocation_id for name, leaf in ( ("XDG_CONFIG_HOME", "config"), ("XDG_STATE_HOME", "state"), diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index 27b50b609..e49142611 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -747,6 +747,22 @@ def test_write_relay_configs_preserves_current_cli_contract(tmp_path: Path): } +def test_write_relay_configs_supports_isolated_working_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.delenv("FABRIC_RELAY_CONFIG_PATH", raising=False) + config_directory = tmp_path / "private-runtime" / "relay-config" + + relay_path, plugin_path = common_utils.write_relay_configs( + relay_config={"agents": {"hermes": {"command": "hermes"}}}, + plugin_config={"version": 1, "components": []}, + config_directory=config_directory, + ) + + assert relay_path == config_directory / "config.toml" + assert plugin_path == config_directory / "plugins.toml" + + def test_write_relay_configs_rejects_legacy_observability(tmp_path: Path): os.environ["FABRIC_RELAY_CONFIG_PATH"] = str(tmp_path / "relay.json") plugin_config = { diff --git a/tests/adapters/test_hermes_dual_mode.py b/tests/adapters/test_hermes_dual_mode.py index 4a260fa56..8d77985b5 100644 --- a/tests/adapters/test_hermes_dual_mode.py +++ b/tests/adapters/test_hermes_dual_mode.py @@ -207,6 +207,9 @@ def test_fake_cli_subprocess_proves_relay_starts_hermes(tmp_path: Path, monkeypa assert child_args[child_args.index("--provider") + 1] == "custom" with launch.config_path.open("rb") as stream: relay_config = tomllib.load(stream) + assert launch.config_path.parent == ( + tmp_path / "hermes-home" / "relay-cli" / "runtime-123" / "relay-config" + ) assert relay_config["agents"]["hermes"]["command"] == str(hermes) assert relay_config["upstream"]["openai_base_url"] == "https://models.example/v1"