Skip to content

feat(hermes): support native and CLI-wrapped Relay execution - #75

Closed
bbednarski9 wants to merge 4 commits into
NVIDIA:mainfrom
bbednarski9:bbednarski/hermes-relay-dual-mode
Closed

feat(hermes): support native and CLI-wrapped Relay execution#75
bbednarski9 wants to merge 4 commits into
NVIDIA:mainfrom
bbednarski9:bbednarski/hermes-relay-dual-mode

Conversation

@bbednarski9

@bbednarski9 bbednarski9 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Overview

Adds two explicit NeMo Relay execution strategies to the existing Hermes adapter while preserving the current native behavior as the default:

  • native_plugin: Hermes runs in-process and uses Relay's Python managed-execution/plugin APIs.
  • cli_wrapper: Fabric launches nemo-relay run --agent hermes, allowing Relay to own the transient gateway, hooks, child process, and cleanup lifecycle.

This also aligns Fabric's typed Relay configuration with the Relay 0.6 contract: observability version 2, ATOF sinks, OpenInference/OTLP mark projection and attribute mappings, ordered dynamic-plugin activation specs, canonical plugins.toml input, and sink-aware artifact discovery.

The goal is to let the same Fabric Hermes evaluation run through either integration boundary without creating a second Hermes adapter or changing the provider request selected by the evaluation.

What changed

One Hermes adapter, two launch modes
  • Adds harness.settings.relay_launch_mode with native_plugin as the backward-compatible default and cli_wrapper as an opt-in mode.
  • Adds harness.settings.relay_cli_command so evaluations can select an installed or source-built Relay CLI.
  • Preserves model, provider endpoint, runtime/session identity, working directory, toolsets, timeout settings, and Hermes's 90-iteration default in both modes.
  • Keeps Hermes's in-process observability/nemo_relay plugin enabled only in native mode; CLI mode delegates lifecycle ownership to nemo-relay run.
  • Reports the selected mode and Relay emitter in the Fabric RunOutput metadata.
Relay 0.6 typed configuration
  • Makes observability version 2 canonical and rejects legacy version-1 documents with an actionable compatibility error.
  • Replaces legacy ATOF endpoint fields with tagged file/stream sinks.
  • Adds typed stream transport, environment-backed headers, field-name policy, timeout, mark projection, excluded mark names, and attribute mappings.
  • Adds ordered dynamic-plugin activation specifications for native and worker plugins.
  • Accepts a canonical Relay plugins.toml as the shared plugin configuration source.
  • Updates generated JSON schemas and Python model surfaces.
Invocation-scoped dynamic plugins
  • Native mode uses Relay's owned Python activation contract and retains the activation across the Hermes call.
  • CLI mode provisions plugins through Relay's supported add / enable / validate lifecycle in invocation-isolated XDG directories before starting Hermes.
  • Plugin configuration, activation receipts, and Relay artifacts remain invocation scoped.
  • Tracked code and fixtures use first-party or neutral plugin identities; concrete second-party validation remains local-only.
Artifact and compatibility behavior
  • Discovers ATOF artifacts from configured version-2 file sinks.
  • Preserves ATIF and OpenInference collection.
  • Passes provider codec identity through Relay rather than adding a Fabric translation layer.
  • Keeps direct Hermes behavior unchanged when Relay telemetry is disabled.

Where should the reviewer start?

  1. adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py for launch-mode selection and lifecycle ownership.
  2. adapters/common/src/nemo_fabric_adapters/common/utils.py for canonical config loading, Relay 0.6 normalization, dynamic-plugin provisioning, and artifact discovery.
  3. crates/fabric-core/src/config.rs and python/src/nemo_fabric/models.py for the typed public contract.
  4. tests/adapters/test_hermes_dual_mode.py for mode-specific regression coverage.
  5. Generated schemas and docs/sdk/python.mdx for the external configuration surface.

Validation

Local regression checks after rebasing onto current main (b7530e9):

  • cargo fmt --all -- --check
  • cargo test --workspace --locked — passed
  • uv run --no-sync pytest -q — 329 passed, 10 skipped
  • git diff --check — passed

Real Terminal-Bench 2.0 smoke validation used the same regex-log task and qualified Anthropic model in both modes:

Mode Benchmark result Agent exceptions Telemetry
cli_wrapper reward 1.0 0 ATOF, ATIF, OpenInference
native_plugin reward 1.0 0 ATOF, ATIF, OpenInference

Both runs preserved the selected provider/model and produced coherent Hermes session artifacts. Event counts are intentionally not expected to match because CLI mode observes the Relay gateway boundary while native mode observes Hermes's managed Python boundary.

Draft blockers and follow-up gates

This PR is intentionally draft:

  • Relay 0.6 publication: this feature requires nemo-relay~=0.6.0, but 0.6 is not yet available from the configured package index. The lockfiles therefore still resolve 0.5 and uv lock --check cannot pass. Refresh the root and adapter lockfiles once 0.6 is published.
  • Hermes compatibility: native managed codec selection and deterministic Relay version probing depend on NousResearch/hermes-agent#65104, or a Hermes release containing it.
  • Tracked interception gate: before marking ready, add a deterministic tracked smoke that proves a neutral dynamic interceptor and a first-party Relay component affect a real managed request in both modes. Existing tracked tests prove configuration/lifecycle behavior; local real-service tests prove activation and artifacts.
  • Process cancellation gate: add explicit failure/cancellation coverage for the Relay-to-Hermes subprocess tree in CLI mode.
  • Native continuation telemetry: a local length-truncated Hermes continuation exposed one missing completed LLM lifecycle span. This does not change benchmark output, but exact lifecycle attribution should be resolved in the Hermes integration before claiming telemetry parity.
  • Generated Rust reference: refresh the Rust API reference after the dependency and lockfile gate is resolved; the checked-in Python reference and JSON schemas are current.

Compatibility

  • Existing Hermes configurations continue to use native_plugin when relay_launch_mode is absent.
  • CLI-wrapper mode is opt-in.
  • Relay observability version 1 is deliberately unsupported on this branch; callers receive a clear Relay 0.6 compatibility error.
  • No new adapter package or retired hermes_cli adapter is introduced.

Related Issues and Work

  • Depends on NousResearch/hermes-agent#65104.

  • Builds on NeMo Relay's 0.6 Python dynamic-plugin host and transparent Hermes runner architecture.

  • This contribution is focused on one Hermes/Relay integration objective.

  • I searched for duplicate open NeMo Fabric PRs before opening this draft.

Summary by CodeRabbit

  • New Features

    • Added NeMo Relay 0.6 observability v2 support with ordered ATOF sinks (file + streaming) and enhanced file/ATIF handling.
    • Added canonical plugins.toml support plus invocation-scoped dynamic plugin activation and provisioning.
    • Added Hermes relay native_plugin and cli_wrapper launch modes with improved command/config isolation.
    • Added OTLP mark projection, exclusions, and attribute mappings.
  • Bug Fixes

    • Clearer rejection of unsupported Relay versions and legacy v1 observability configuration.
  • Documentation

    • Updated SDK, Hermes, and API/schema references for the new Relay configuration shapes.

@bbednarski9

Copy link
Copy Markdown
Contributor Author

Architecture and lifecycle diagrams

One adapter, two Relay ownership boundaries

flowchart TD
    F[NeMo Fabric runtime] --> H[Existing nvidia.fabric.hermes adapter]
    H --> M{relay_launch_mode}

    M -->|native_plugin default| N[Hermes Python execution]
    N --> HP[Hermes observability/nemo_relay plugin]
    HP --> RP[Relay Python managed LLM and tool APIs]
    RP --> FP[First-party components]
    RP --> DP[Dynamic plugins]
    RP --> U1[Provider endpoint]

    M -->|cli_wrapper opt-in| C[nemo-relay run --agent hermes]
    C --> G[Transient Relay gateway and hooks]
    G --> HC[Hermes CLI child]
    HC --> G
    G --> FP2[First-party components]
    G --> DP2[Dynamic plugins]
    G --> U2[Provider endpoint]

    RP --> A[ATOF / ATIF / OpenInference artifacts]
    G --> A
Loading

The provider request stays under the Hermes adapter in both cases. The difference is where Relay owns interception and lifecycle: inside the Python process for native mode, or at the transient gateway/process boundary for CLI-wrapper mode.

Native-plugin invocation

sequenceDiagram
    participant Fabric as Fabric Hermes adapter
    participant RelayPy as Relay Python host
    participant Hermes as Hermes native runtime
    participant Plugin as Relay components/plugins
    participant Provider as Model provider

    Fabric->>Fabric: Resolve Relay v2 config and dynamic specs
    Fabric->>RelayPy: Initialize owned plugin activation
    RelayPy->>Plugin: Validate and register in declared order
    Fabric->>Hermes: Run one Hermes session
    Hermes->>RelayPy: Managed LLM/tool request
    RelayPy->>Plugin: Run interceptors and guardrails
    RelayPy->>Provider: Dispatch effective request
    Provider-->>RelayPy: Provider response
    RelayPy->>Plugin: Run response/lifecycle hooks
    RelayPy-->>Hermes: Effective response
    Hermes-->>Fabric: Run result and session artifacts
    Fabric->>RelayPy: Close activation after session teardown
Loading

CLI-wrapper invocation

sequenceDiagram
    participant Fabric as Fabric Hermes adapter
    participant Lifecycle as Relay plugin lifecycle
    participant Relay as nemo-relay run
    participant Hermes as Hermes CLI child
    participant Provider as Model provider

    Fabric->>Fabric: Write config.toml and plugins.toml
    Fabric->>Lifecycle: plugins add / enable / validate
    Lifecycle-->>Fabric: Invocation-scoped activation receipt
    Fabric->>Relay: run --agent hermes --plugin-config-path ...
    Relay->>Relay: Start transient gateway and hooks
    Relay->>Hermes: Start Hermes with preserved args/session
    Hermes->>Relay: Provider request through gateway
    Relay->>Provider: Dispatch intercepted request
    Provider-->>Relay: Provider response
    Relay-->>Hermes: Intercepted response
    Hermes-->>Relay: Exit status
    Relay->>Relay: Flush telemetry and stop gateway
    Relay-->>Fabric: Exit status and artifacts
Loading

Configuration and artifact flow

flowchart LR
    FC[Fabric YAML / typed config] --> PC[Canonical Relay plugin document]
    FC --> HM[Hermes launch settings]
    PC --> V2[Relay observability v2]
    PC --> DY[Ordered dynamic plugin specs]
    V2 --> FS[ATOF file sinks]
    V2 --> OI[OpenInference / OTLP mappings]
    DY --> NA[Native owned activation]
    DY --> CL[CLI lifecycle activation]
    FS --> COL[Fabric artifact discovery]
    OI --> COL
    HM --> NA
    HM --> CL
Loading

Review note: the draft intentionally records the Relay 0.6 package publication, Hermes compatibility PR, deterministic interception smoke, and CLI cancellation coverage as gates before moving to ready-for-review.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Relay integration now requires observability v2, models ATOF output as typed file or stream sinks, supports canonical plugins.toml and dynamic plugins, and adds Hermes native and CLI-wrapper launch modes with lifecycle receipts and expanded validation.

Changes

Relay v2 contracts and SDK models

Layer / File(s) Summary
Relay configuration contracts
crates/fabric-core/..., python/src/nemo_fabric/..., schemas/...
Relay observability defaults to v2; ATOF uses typed sinks; dynamic plugins, plugin paths, OTLP mappings, and mark projection controls are added across Rust, Python, and JSON schemas.
SDK exports and configuration wiring
python/src/nemo_fabric/__init__.py, python/src/nemo_fabric/models.py, python/src/nemo_fabric/types.py
The SDK exposes new sink and dynamic-plugin models and accepts plugin_config_path and dynamic_plugins through enable_relay.
Examples and reference documentation
docs/..., examples/..., adapters/hermes/README.md
Examples and documentation describe sink-based Relay configuration, canonical plugin files, dynamic plugins, PII processing, and Hermes launch modes.

Relay translation and plugin lifecycle

Layer / File(s) Summary
Relay compatibility and conversion
adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py, adapters/common/src/nemo_fabric_adapters/common/utils.py
Relay versions older than 0.6 are rejected; configuration loading, v2 conversion, artifact collection, dynamic-plugin mapping, and CLI lifecycle provisioning are updated.
Runtime propagation
crates/fabric-core/src/runtime.rs, crates/fabric-core/src/error.rs
Generated runtime configuration carries canonical plugin paths and dynamic plugin entries, with an invalid Relay configuration error variant.

Hermes Relay launch modes

Layer / File(s) Summary
Native and CLI-wrapper orchestration
adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
Hermes selects native or CLI-wrapper Relay execution, manages continuation sessions and plugin activation, invokes wrapped subprocesses, redacts commands, and returns launch and activation metadata.
Hermes packaging and documentation
adapters/deepagents/pyproject.toml, adapters/hermes/README.md
Relay dependency constraints target version 0.6 and the adapter documents both execution strategies.

Validation and fixtures

Layer / File(s) Summary
Contract and lifecycle tests
tests/adapters/*, tests/python/test_sdk_contract.py, crates/fabric-core/src/config.rs
Tests cover v2 rejection, sink serialization, canonical plugin loading, dynamic plugin ordering and provisioning, native activation, CLI-wrapper execution, and SDK mappings.
End-to-end artifacts and profiles
tests/e2e/test_hermes_e2e.py, tests/fixtures/...
Hermes artifact assertions and Relay fixture profiles are updated for sink-based output and variable trajectory records.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits format and clearly summarizes the Hermes Relay execution-mode change.
Description check ✅ Passed The description mostly matches the template with Overview, reviewer start, validation, and checklist items filled in.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 force-pushed the bbednarski/hermes-relay-dual-mode branch from a9296c6 to 76a0ed0 Compare July 16, 2026 14:49
@bbednarski9
bbednarski9 marked this pull request as ready for review July 16, 2026 14:54
@bbednarski9
bbednarski9 requested a review from a team as a code owner July 16, 2026 14:54
@linear

linear Bot commented Jul 16, 2026

Copy link
Copy Markdown

FABRIC-80

@bbednarski9
bbednarski9 marked this pull request as draft July 16, 2026 15:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
python/src/nemo_fabric/types.py (1)

754-766: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject mixed external and inline Relay plugin configuration.

Calling enable_relay(plugin_config_path=...) after configuring observability, components, dynamic_plugins, or policy produces a mapping that the Rust resolver rejects. The reverse call order has the same problem.

Validate the prospective Relay mapping before assignment, mirror the guard in models.py, and test both call orders.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/nemo_fabric/types.py` around lines 754 - 766, Update the Relay
configuration logic in enable_relay to reject mappings that mix
plugin_config_path with inline observability, components, dynamic_plugins, or
policy settings. Validate the prospective relay mapping before assigning it,
using the same guard established in models.py, and add coverage for both call
orders: external plugin configuration after inline settings and inline settings
after plugin configuration.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@adapters/common/src/nemo_fabric_adapters/common/utils.py`:
- Around line 293-347: Update the relay.dynamic_plugins loading path to validate
each spec’s plugin_id and kind before appending it to resolved, matching the
manifest identity rules used by the external plugins.dynamic branch: require a
string plugin_id and restrict kind to rust_dynamic or worker. Raise a clear
indexed ValueError for invalid identity data so relay_api_dynamic_plugins
receives only validated specs.
- Around line 363-386: Update the ATOF file-sink handling in the configuration
loop so multiple file sinks without explicit filenames receive distinct default
filenames and cannot overwrite each other. Preserve explicitly configured
sink["filename"] values, while deriving a deterministic unique filename from
each sink’s position or configuration for missing values; ensure the resulting
path remains under the resolved output directory.
- Around line 758-780: Add a bounded timeout to the subprocess.run call in
_run_relay_lifecycle_command and catch subprocess.TimeoutExpired, raising a
clear RuntimeError that identifies the Relay lifecycle action and timeout.
Preserve the existing nonzero-return handling for commands that complete
normally.
- Around line 350-361: Update normalize_relay_output_dirs to obtain the
identifier through the existing runtime_id(payload) helper instead of indexing
runtime_context(payload), preserving its ValueError handling. Rename the local
identifier variable to current_runtime_id and update all subsequent references
in the function to avoid shadowing the helper.
- Around line 388-402: Align the ATIF filename default used by
_relay_api_atif_config with normalize_relay_output_dirs by changing it to
"trajectory-{session_id}.atif.json". Keep the existing normalized-config
behavior and other ATIF settings unchanged so both paths produce consistent
artifact names.
- Around line 782-817: Update _relay_plugin_manifest_registered and
_attach_relay_dynamic_plugin_config to resolve relative manifest references
against plugin_config_path.parent before comparing them with the configured
target. Preserve absolute-path handling and use the same base-directory
resolution in both helpers so registration checks and config attachment remain
consistent regardless of the process working directory.

In `@adapters/hermes/README.md`:
- Around line 8-14: Update the native_plugin description in the adapter README
to state that Relay is activated through the Python API only when Relay
telemetry is enabled, while preserving the existing Python SDK and
dynamic-plugin host details.

In `@adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py`:
- Around line 416-449: The invoke_relay_wrapped_hermes function must replace
blocking subprocess.run with async subprocess execution, enforce a timeout, and
handle task cancellation by terminating the child process group. Preserve the
existing result shape and stdout/stderr handling, while ensuring hung
Relay/Hermes processes are bounded and cleaned up before returning or
propagating cancellation.
- Around line 367-380: Restrict the scope of native_relay_plugin_environment to
the synchronous _invoke_hermes call only; do not enter it across plugin
initialization, context entry, or awaits. Restructure the native execution flow
around _invoke_hermes so each invocation sets and restores
HERMES_NEMO_RELAY_PLUGINS_TOML immediately around that call, or serialize native
executions to prevent concurrent environment overrides.

In `@crates/fabric-core/src/config.rs`:
- Around line 685-699: The RelayDynamicPluginConfig contract currently accepts
empty plugin_id values; reject them consistently across Rust validation,
generated schema, and native Python declarations. Update validate_relay_config
and the RelayDynamicPluginConfig plugin_id schema/type metadata to require at
least one character, synchronize the corresponding Python declaration, and add a
regression test confirming invalid identifiers are rejected before provisioning.

In `@docs/sdk/python.mdx`:
- Around line 193-199: Regenerate the Python API reference for
FabricConfig.enable_relay so its documented signature includes
plugin_config_path, matching the current definition in models.py. Update the
generated nemo_fabric.models reference without changing the source API or
surrounding documentation.

In `@pyproject.toml`:
- Line 80: Update the nemo-relay dependency constraint in the project dependency
configuration to use an available published release instead of the nonexistent
0.6.0 version. Preserve the existing Relay extra dependency declaration and
compatible version constraint style.

In `@python/src/nemo_fabric/models.py`:
- Around line 269-275: Update RelayConfig to reject plugin_config_path when
inline observability, components, dynamic_plugins, or policy are also provided,
matching validate_relay_config. Remove dict fallbacks from sinks and
dynamic_plugins, replace arbitrary attribute_mappings dictionaries with their
concrete typed model, and update enable_relay to use model_validate for
dictionary inputs instead of dict conversion.

In `@tests/adapters/test_adapaters_common_utils.py`:
- Around line 547-550: Update the affected test to set RELAY_TOKEN through
os.environ["RELAY_TOKEN"] instead of monkeypatch.setenv, and remove the
now-unused monkeypatch fixture parameter from the test signature.
- Around line 546-565: The test
test_relay_api_plugin_config_uses_observability_v2_and_first_party_pii uses
hard-coded /tmp paths that trigger Ruff S108. Add the pytest tmp_path fixture,
derive both relay output-directory paths from it, and update the expected
serialized configuration values accordingly; apply the same change to the
related test section around the first-party PII assertions.

In `@tests/adapters/test_hermes_dual_mode.py`:
- Around line 267-279: Replace the hand-written _Activation mock with a
MagicMock fixture whose __aenter__ and __aexit__ attributes are AsyncMock
instances, preserving the report data and context-manager behavior used by the
tests. Update the relevant assertions to verify both async methods were awaited,
and remove the custom class and its annotation warnings.
- Around line 66-73: Extend
test_explicit_max_tokens_is_shared_by_native_and_cli_modes to exercise both CLI
configuration and native _invoke_hermes execution. Ensure _invoke_hermes uses
the shared model-level max_tokens value with the same precedence as
build_hermes_config, then assert the native AIAgent arguments receive 4096
instead of the hardcoded default.

---

Outside diff comments:
In `@python/src/nemo_fabric/types.py`:
- Around line 754-766: Update the Relay configuration logic in enable_relay to
reject mappings that mix plugin_config_path with inline observability,
components, dynamic_plugins, or policy settings. Validate the prospective relay
mapping before assigning it, using the same guard established in models.py, and
add coverage for both call orders: external plugin configuration after inline
settings and inline settings after plugin configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 9ad83b3b-55c2-4e91-a51a-fc3ee750479d

📥 Commits

Reviewing files that changed from the base of the PR and between b7530e9 and 76a0ed0.

📒 Files selected for processing (31)
  • .gitignore
  • adapters/common/src/nemo_fabric_adapters/common/relay_gateway.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/deepagents/pyproject.toml
  • adapters/hermes/README.md
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
  • crates/fabric-core/src/config.rs
  • crates/fabric-core/src/error.rs
  • crates/fabric-core/src/runtime.rs
  • docs/reference/api/python-library-reference/index.md
  • docs/reference/api/python-library-reference/nemo_fabric.models.md
  • docs/sdk/python.mdx
  • examples/code_review_agent/config.py
  • examples/harbor/demo/task/environment/fabric/configs/hermes-relay.yaml
  • pyproject.toml
  • python/src/nemo_fabric/__init__.py
  • python/src/nemo_fabric/models.py
  • python/src/nemo_fabric/types.py
  • schemas/adapter-invocation.schema.json
  • schemas/agent.schema.json
  • schemas/effective-config.schema.json
  • schemas/run-plan.schema.json
  • tests/adapters/test_adapaters_common_utils.py
  • tests/adapters/test_adapters_common_relay_gateway.py
  • tests/adapters/test_hermes_adapter.py
  • tests/adapters/test_hermes_dual_mode.py
  • tests/e2e/test_hermes_e2e.py
  • tests/fixtures/file-config-agent/profiles/mcp-github.yaml
  • tests/fixtures/file-config-agent/profiles/relay-openinference.yaml
  • tests/fixtures/file-config-agent/profiles/relay.yaml
  • tests/python/test_sdk_contract.py

Comment on lines +293 to +347
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 = 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()
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validation gap between the two dynamic-plugin loading paths.

The external plugins.dynamic TOML branch (lines 293-329) strictly validates manifest identity (plugin_id, kind in {"rust_dynamic", "worker"}) by reading the manifest file. The relay.dynamic_plugins branch (lines 331-347) performs no equivalent validation — it only checks that each entry is a dict and resolves path fields. If plugin_id/kind are missing or malformed in this second path, the failure surfaces later as an unhelpful KeyError in relay_api_dynamic_plugins (line 484-485) rather than a clear validation error here.

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 295-295: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 299-299: Prefer TypeError exception for invalid type

(TRY004)


[warning] 299-299: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 302-302: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 308-308: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 315-317: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 320-320: Prefer TypeError exception for invalid type

(TRY004)


[warning] 320-320: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 333-333: Prefer TypeError exception for invalid type

(TRY004)


[warning] 333-333: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 338-338: Prefer TypeError exception for invalid type

(TRY004)


[warning] 338-338: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/utils.py` around lines 293 -
347, Update the relay.dynamic_plugins loading path to validate each spec’s
plugin_id and kind before appending it to resolved, matching the manifest
identity rules used by the external plugins.dynamic branch: require a string
plugin_id and restrict kind to rust_dynamic or worker. Raise a clear indexed
ValueError for invalid identity data so relay_api_dynamic_plugins receives only
validated specs.

Comment on lines +350 to +361
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bypasses existing runtime_id() helper — inconsistent error handling.

Line 354 directly indexes runtime_context(payload)["runtime_id"], which raises a raw KeyError if missing. The module already defines runtime_id(payload) (lines 98-104) that validates this and raises a clear ValueError. Reuse it here for consistent error semantics.

🔧 Proposed fix
-    base = Path(config_root(payload)).resolve()
-    runtime_id = runtime_context(payload)["runtime_id"]
+    base = Path(config_root(payload)).resolve()
+    current_runtime_id = runtime_id(payload)

(and update subsequent usages of the local runtime_id variable to current_runtime_id to avoid shadowing the module-level function name)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
def normalize_relay_output_dirs(
plugin_config: dict[str, Any], payload: dict[str, Any]
) -> None:
base = Path(config_root(payload)).resolve()
current_runtime_id = runtime_id(payload)
for component in plugin_config.get("components", []):
if component.get("kind") != "observability":
continue
config = component.setdefault("config", {})
version = int(config.setdefault("version", 2))
if version != 2:
raise ValueError("NeMo Relay observability config version 2 is required")
🧰 Tools
🪛 Ruff (0.15.21)

[warning] 361-361: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/utils.py` around lines 350 -
361, Update normalize_relay_output_dirs to obtain the identifier through the
existing runtime_id(payload) helper instead of indexing
runtime_context(payload), preserving its ValueError handling. Rename the local
identifier variable to current_runtime_id and update all subsequent references
in the function to avoid shadowing the helper.

Comment on lines +363 to +386
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Multiple ATOF file sinks share the same default output filename, risking collisions.

The loop at lines 372-386 assigns every type == "file" sink to base / "artifacts" / "relay" / runtime_id (or the sink's own output_directory joined the same way) and defaults filename to "events.atof.jsonl" for all of them. If a user configures two file sinks without explicit filenames (for example, to split by category), they will silently write to the same path and clobber each other's output.

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 367-367: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/utils.py` around lines 363 -
386, Update the ATOF file-sink handling in the configuration loop so multiple
file sinks without explicit filenames receive distinct default filenames and
cannot overwrite each other. Preserve explicitly configured sink["filename"]
values, while deriving a deterministic unique filename from each sink’s position
or configuration for missing values; ensure the resulting path remains under the
resolved output directory.

Comment on lines +388 to +402
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

ATIF filename_template default diverges from _relay_api_atif_config's default.

normalize_relay_output_dirs defaults filename_template to "trajectory-{session_id}.atif.json" (line 400), but _relay_api_atif_config (lines 556-558) defaults the same field to "nemo-relay-atif-{session_id}.json". If _relay_api_atif_config is ever invoked on a config that skipped normalization (or before normalization runs), the effective default filename silently differs from what normalize_relay_output_dirs/collect_relay_artifacts expect, producing inconsistent artifact naming.

🔧 Align defaults
-        filename_template=value.get(
-            "filename_template", "nemo-relay-atif-{session_id}.json"
-        ),
+        filename_template=value.get(
+            "filename_template", "trajectory-{session_id}.atif.json"
+        ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/utils.py` around lines 388 -
402, Align the ATIF filename default used by _relay_api_atif_config with
normalize_relay_output_dirs by changing it to
"trajectory-{session_id}.atif.json". Keep the existing normalized-config
behavior and other ATIF settings unchanged so both paths produce consistent
artifact names.

Comment on lines +758 to +780
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}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Missing timeout on Relay CLI subprocess call.

subprocess.run at line 766 has no timeout, so if the Relay CLI hangs (for example during plugins add/enable/validate), this call blocks indefinitely with no recovery path. This is a critical external-call hazard for a lifecycle operation invoked during agent provisioning.

🔧 Add a timeout and handle expiry
-    completed = subprocess.run(
+    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,
+        timeout=60,
     )

Wrap the call in a try/except subprocess.TimeoutExpired to raise a clear RuntimeError on hang.

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 765-773: Command coming from incoming request
Context: subprocess.run(
[str(executable), "--config", str(relay_config_path), *args],
cwd=cwd,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🪛 Ruff (0.15.21)

[error] 766-766: subprocess call: check for execution of untrusted input

(S603)


[warning] 777-779: Avoid specifying long messages outside the exception class

(TRY003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/common/src/nemo_fabric_adapters/common/utils.py` around lines 758 -
780, Add a bounded timeout to the subprocess.run call in
_run_relay_lifecycle_command and catch subprocess.TimeoutExpired, raising a
clear RuntimeError that identifies the Relay lifecycle action and timeout.
Preserve the existing nonzero-return handling for commands that complete
normally.

Comment on lines +269 to +275
sinks: list[
Annotated[
RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig,
Field(discriminator="type"),
]
| dict[str, Any]
] = Field(default_factory=list)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

models_file="$(fd -a '^models\.py$' python/src/nemo_fabric | head -n1)"
tests_file="$(fd -a '^test_sdk_contract\.py$' tests/python python/tests | head -n1)"

ast-grep outline "$models_file" \
  --match 'RelayAtofConfig|RelayOtlpConfig|RelayDynamicPluginConfig|RelayConfig|FabricConfig' \
  --view expanded

sed -n '243,290p;340,400p;544,594p' "$models_file"
rg -n -C3 'RelayAtofConfig|attribute_mappings|dynamic_plugins|plugin_config_path' "$tests_file"

Repository: NVIDIA/NeMo-Fabric

Length of output: 1900


🏁 Script executed:

# Get the actual file content at the specified line ranges
models_file="python/src/nemo_fabric/models.py"
echo "=== Lines 269-275 (sinks union) ==="
sed -n '269,275p' "$models_file"

echo -e "\n=== Lines 355 (attribute_mappings) ==="
sed -n '355p' "$models_file"

echo -e "\n=== Lines 377-399 (dynamic_plugins) ==="
sed -n '377,399p' "$models_file"

echo -e "\n=== Lines 547-592 (RelayConfig full definition) ==="
sed -n '547,592p' "$models_file"

echo -e "\n=== Around plugin_config_path ==="
rg -n 'plugin_config_path' "$models_file" -B2 -A2

Repository: NVIDIA/NeMo-Fabric

Length of output: 4120


🏁 Script executed:

# Check if there are model validators that enforce constraints
rg -n 'field_validator|model_validator' "python/src/nemo_fabric/models.py" -A5 | grep -A5 "RelayConfig\|dynamic_plugins\|attribute_mappings\|sinks"

# Check if RelayConfig has any validation logic for plugin_config_path conflicts
rg -n "class RelayConfig" "python/src/nemo_fabric/models.py" -A100 | head -n 120

# Look for Rust equivalents to understand parity requirements
fd -a 'models.rs' | head -n3 | xargs rg -l 'RelayConfig\|plugin_config_path' 2>/dev/null || echo "No Rust models.rs found in obvious locations"

# Check test coverage for these edge cases
rg -n 'plugin_config_path' "tests/" -t py -B2 -A2 | head -40

Repository: NVIDIA/NeMo-Fabric

Length of output: 7889


🏁 Script executed:

# Search for Rust models or documentation about the plugin_config_path conflict
find . -name "*.rs" -type f | xargs rg -l 'RelayConfig\|plugin_config_path' 2>/dev/null | head -5

# Check if there's any validation logic in enable_relay or RelayConfig
rg -n 'class RelayConfig|enable_relay' "python/src/nemo_fabric/models.py" -A50 | grep -E 'validator|plugin_config_path' -B2 -A2

# Look for any test that validates rejecting plugin_config_path + inline config
rg -n 'plugin_config_path' "tests/" -t py --multiline-dotall -B5 -A5 | grep -E 'assert|raises|plugin_config_path.*components|plugin_config_path.*observability' -B3 -A3

Repository: NVIDIA/NeMo-Fabric

Length of output: 2170


🏁 Script executed:

# Look for Rust source code more broadly, including fabric-core or relay
find . -name "*.rs" -type f -path "*/src/*" | head -20

# Check if there's documentation about plugin_config_path being mutually exclusive
rg -i 'plugin.config.path.*mutually|mutually.*plugin.config.path|external.*inline|inline.*external' . -t py -t md -t txt

# Verify whether RelayConfig actually allows both plugin_config_path and inline config simultaneously
# by checking the test that sets plugin_config_path alongside observability, components, dynamic_plugins
sed -n '770,825p' "tests/adapters/test_adapaters_common_utils.py"

Repository: NVIDIA/NeMo-Fabric

Length of output: 2502


🏁 Script executed:

# Check Rust config.rs for RelayConfig and whether it rejects plugin_config_path + inline
rg -n 'plugin_config_path\|struct RelayConfig' "./crates/fabric-core/src/config.rs" -A30 | head -80

# Look for validation logic in Rust that would reject the coexistence
rg -n 'validate|check' "./crates/fabric-core/src/config.rs" | grep -i 'relay\|plugin' -B2 -A2

# Check if Python tests actually test the conflict scenario
rg -n 'enable_relay' "tests/sdk" -t py -A20 | grep -E 'plugin_config_path.*components|plugin_config_path.*observability|plugin_config_path.*dynamic_plugins' -B10 -A5

Repository: NVIDIA/NeMo-Fabric

Length of output: 455


🏁 Script executed:

# Examine the Rust validate_relay_config function
sed -n '1862,1920p' "./crates/fabric-core/src/config.rs"

# Look for the RelayConfig struct definition in Rust
rg -n 'struct RelayConfig' "./crates/fabric-core/src/config.rs" -A40 | head -50

# Check test files for SDK contract tests
find . -name "test_sdk*.py" -o -name "*test*contract*.py" | head -10

# See how enable_relay is used in practice
rg -n 'enable_relay' "tests/" -t py -B2 -A15 | grep -E 'plugin_config_path|components|dynamic_plugins|observability' -B5 -A5 | head -50

Repository: NVIDIA/NeMo-Fabric

Length of output: 8220


🏁 Script executed:

# Check if RelayConfig has any validators that enforce the plugin_config_path constraint
rg -n '`@model_validator`|`@field_validator`' "python/src/nemo_fabric/models.py" -A10 | grep -B5 -A5 'RelayConfig\|plugin_config_path'

# Look at what the current enable_relay test actually passes
sed -n '220,330p' "tests/python/test_sdk_contract.py"

Repository: NVIDIA/NeMo-Fabric

Length of output: 3808


Add validators to RelayConfig to enforce Rust's plugin_config_path and inline-config mutual exclusivity, and use typed models for sinks and dynamic_plugins.

The Rust validator (validate_relay_config in fabric-core/src/config.rs) rejects any attempt to combine plugin_config_path with inline observability, components, dynamic_plugins, or policy. Python currently allows this invalid combination. Additionally, sinks and dynamic_plugins accept dict[str, Any] fallbacks that bypass discriminator validation, and attribute_mappings accepts arbitrary dict[str, str] without schema enforcement.

Remove the dict fallbacks from sinks and dynamic_plugins, use concrete models for attribute_mappings, and add a model validator to RelayConfig that rejects the plugin_config_path + inline-config combination:

Proposed changes
-    sinks: list[
-        Annotated[
-            RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig,
-            Field(discriminator="type"),
-        ]
-        | dict[str, Any]
-    ] = Field(default_factory=list)
+    sinks: list[
+        Annotated[
+            RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig,
+            Field(discriminator="type"),
+        ]
+    ] = Field(default_factory=list)

+class RelayOtlpAttributeMapping(FabricBaseModel):
+    key: str
+    alias: str
+
-    attribute_mappings: list[dict[str, str]] = Field(default_factory=list)
+    attribute_mappings: list[RelayOtlpAttributeMapping] = Field(default_factory=list)

-    dynamic_plugins: list[RelayDynamicPluginConfig | dict[str, Any]] = Field(
+    dynamic_plugins: list[RelayDynamicPluginConfig] = Field(
         default_factory=list
     )

+class RelayConfig(FabricBaseModel):
+    ...
+    `@model_validator`(mode="before")
+    `@classmethod`
+    def reject_plugin_config_path_with_inline(cls, value: Any) -> Any:
+        if isinstance(value, Mapping):
+            plugin_config_path = value.get("plugin_config_path")
+            if plugin_config_path is not None and any(
+                value.get(k) for k in ["observability", "components", "dynamic_plugins", "policy"]
+            ):
+                raise ValueError(
+                    "plugin_config_path cannot be combined with inline Relay observability, "
+                    "components, dynamic_plugins, or policy"
+                )
+        return value

Update enable_relay to use model_validate for dict inputs instead of dict() conversion.

As per path instructions, Python SDK changes must preserve typed API consistency and parity with the native extension. This ensures the builder cannot emit configurations that Rust will reject at validation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sinks: list[
Annotated[
RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig,
Field(discriminator="type"),
]
| dict[str, Any]
] = Field(default_factory=list)
sinks: list[
Annotated[
RelayAtofFileSinkConfig | RelayAtofStreamSinkConfig,
Field(discriminator="type"),
]
] = Field(default_factory=list)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/src/nemo_fabric/models.py` around lines 269 - 275, Update RelayConfig
to reject plugin_config_path when inline observability, components,
dynamic_plugins, or policy are also provided, matching validate_relay_config.
Remove dict fallbacks from sinks and dynamic_plugins, replace arbitrary
attribute_mappings dictionaries with their concrete typed model, and update
enable_relay to use model_validate for dictionary inputs instead of dict
conversion.

Sources: Coding guidelines, Path instructions

Comment thread tests/adapters/test_adapaters_common_utils.py
Comment on lines +547 to +550
monkeypatch: pytest.MonkeyPatch,
):
pytest.importorskip("nemo_relay")
monkeypatch.setenv("RELAY_TOKEN", "test-only-value")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use os.environ instead of monkeypatch.setenv.

Set os.environ["RELAY_TOKEN"] and remove the unused fixture parameter.

As per coding guidelines, “Use os.environ to modify environment variables in tests; do not use monkeypatch.setenv.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/adapters/test_adapaters_common_utils.py` around lines 547 - 550, Update
the affected test to set RELAY_TOKEN through os.environ["RELAY_TOKEN"] instead
of monkeypatch.setenv, and remove the now-unused monkeypatch fixture parameter
from the test signature.

Source: Coding guidelines

Comment on lines +66 to +73
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exercise both execution paths in this parity test.

This only tests build_hermes_config(). Native _invoke_hermes still uses settings.get("max_tokens", 512), so model-level max_tokens is ignored while CLI mode honors it. Align the precedence and assert the actual native AIAgent arguments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/adapters/test_hermes_dual_mode.py` around lines 66 - 73, Extend
test_explicit_max_tokens_is_shared_by_native_and_cli_modes to exercise both CLI
configuration and native _invoke_hermes execution. Ensure _invoke_hermes uses
the shared model-level max_tokens value with the same precedence as
build_hermes_config, then assert the native AIAgent arguments receive 4096
instead of the hardcoded default.

Comment on lines +267 to +279
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Replace the hand-written activation mock with MagicMock and AsyncMock.

Use a fixture with mocked __aenter__ and __aexit__ methods, then assert they were awaited. This also resolves the reported annotation warnings.

As per coding guidelines, “When mocking a class, use unittest.mock.MagicMock or AsyncMock; do not define a new mock class.”

🧰 Tools
🪛 Ruff (0.15.21)

[warning] 273-273: Missing return type annotation for special method __aenter__

(ANN204)


[warning] 277-277: Missing return type annotation for special method __aexit__

(ANN204)


[warning] 277-277: Missing type annotation for *_args

(ANN002)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/adapters/test_hermes_dual_mode.py` around lines 267 - 279, Replace the
hand-written _Activation mock with a MagicMock fixture whose __aenter__ and
__aexit__ attributes are AsyncMock instances, preserving the report data and
context-manager behavior used by the tests. Update the relevant assertions to
verify both async methods were awaited, and remove the custom class and its
annotation warnings.

Sources: Coding guidelines, Linters/SAST tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py`:
- Around line 299-304: Constrain the invocation_id derived in the adapter flow
before constructing isolation_root, rejecting absolute paths and traversal
components or validating the resolved path remains beneath hermes_home /
relay-cli. Preserve valid invocation IDs and ensure unsafe values cannot create
directories or write state outside the sandbox; add a regression test covering
absolute and parent-directory traversal IDs.

In `@tests/adapters/test_adapaters_common_utils.py`:
- Around line 756-763: Extend the test around common_utils.write_relay_configs
to verify that both returned paths, relay_path and plugin_path, correspond to
files written in config_directory. Add existence or content assertions for
config.toml and plugins.toml while preserving the existing path-value
assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: a74d835e-c353-4df6-b3c6-85aa200157fe

📥 Commits

Reviewing files that changed from the base of the PR and between 76a0ed0 and 678988c.

📒 Files selected for processing (4)
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
  • tests/adapters/test_adapaters_common_utils.py
  • tests/adapters/test_hermes_dual_mode.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (10)
**/*.py

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.py: Python public APIs must use type annotations, and native Python binding declarations must remain synchronized with their Rust implementations.
Python files must begin with the specified # SPDX copyright and Apache-2.0 license header.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
**/*.{rs,py}

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*.{rs,py}: Use snake_case for Rust and Python functions and variables; use PascalCase for Rust types and Python classes.
Run tests for every language surface affected by a change. Changes touching the Rust core or public schemas require both Rust and Python test suites.
Public contract changes must keep native Python binding declarations synchronized with their Rust implementations.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
**/*

📄 CodeRabbit inference engine (CONTRIBUTING.md)

**/*: All source files must include the specified SPDX copyright and Apache-2.0 license header using the comment syntax appropriate to the file type.
Release tags must use raw Rust-compatible SemVer without a leading v, such as 0.1.0 or 0.1.0-rc.1.

**/*: Before implementing, explicitly state assumptions, surface ambiguity and tradeoffs, present multiple interpretations when relevant, and ask for clarification rather than silently deciding or proceeding when requirements are unclear.
Prefer the minimum code needed to solve the requested problem: avoid speculative features, unnecessary abstractions, unrequested flexibility, and handling of impossible scenarios; simplify overcomplicated solutions.
When editing existing code, make surgical changes only: do not modify unrelated code, comments, formatting, or pre-existing dead code; match the existing style, and remove only unused imports, variables, or functions introduced by your changes.
Define verifiable success criteria for each task, such as writing regression tests for bugs and invalid-input tests for validation, then verify the implementation against those criteria. For multi-step work, state a brief plan with a verification check for each step.

**/*: Keep pull request branch scope coherent and reviewable.
Run relevant tests under validate-change before opening or updating a pull request.
Format changed files with the language-native formatter.
Update documentation and examples for public behavior changes.
Update dependent maintainer or consumer guidance when code changes affect APIs, bindings, commands, paths, packaging guidance, or best practices.
Use Conventional Commit style for pull request titles: <type>: <concise imperative summary>, choosing the type from the actual change surface. Use fix only for user-facing or runtime product-code bug fixes.
A pull request body must include #### Overview, #### Details, #### Validation, #### Where should the reviewer start?, and `#### Related ...

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
**/*.{rs,py,pyi,json,yaml,yml}

📄 CodeRabbit inference engine (.agents/skills/contribute-api/SKILL.md)

Determine and update every affected public surface, including the CLI, PyO3 bindings, Python SDK, type stubs, schemas, and adapter contract, so they remain in parity.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
tests/**/*.py

📄 CodeRabbit inference engine (.agents/skills/python-tests/SKILL.md)

tests/**/*.py: Use Pytest to run Python tests.
Do not add @pytest.mark.asyncio to tests; async tests are automatically detected by the async runner.
Do not add -> None return annotations to test functions.
When mocking a class, use unittest.mock.MagicMock or AsyncMock, supplying spec when necessary; do not define a new mock class.
Prefix mocked class names with mock, not fake.
Prefer pytest fixtures over helper methods.
Define shared fixtures in conftest.py rather than repeating them across test files.
Define fixtures using @pytest.fixture(name="<fixture_name>"[, scope="<scope>"]) and a <fixture_name>_fixture function; specify scope only when it is not function.
Prefer pytest.mark.parametrize over separate tests for different input types.
Use @pytest.mark.usefixtures when a fixture is needed but its return value is unused.
Use os.environ to modify environment variables in tests; do not use monkeypatch.setenv, because the autouse restore_environ_fixture in tests/conftest.py restores the environment after each test.
Avoid defensive programming in tests; access expected data directly so missing data raises a clear error instead of being silently tolerated.
Run focused tests with uv run pytest -k "<pattern>" and all tests with uv run pytest.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
**/*.{py,pyi}

📄 CodeRabbit inference engine (.agents/skills/validate-change/SKILL.md)

When Python code or a Python-facing adapter changes, run just test-python.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
**/*.{rs,py,pyi,toml}

📄 CodeRabbit inference engine (.agents/skills/validate-change/SKILL.md)

When the PyO3 bridge or package metadata changes, run just build-python and cargo check -p fabric-python --locked.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
tests/adapters/**/*

📄 CodeRabbit inference engine (.agents/skills/validate-change/SKILL.md)

When an adapter or integration changes, run its focused tests under tests/adapters, followed by just test-python.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
{tests/**,python/tests/**}

⚙️ CodeRabbit configuration file

{tests/**,python/tests/**}: Tests should cover the behavior promised by the changed API surface, including error paths, lifecycle cleanup, and SDK/native parity where relevant.

Files:

  • tests/adapters/test_hermes_dual_mode.py
  • tests/adapters/test_adapaters_common_utils.py
{adapters/**,examples/**}

⚙️ CodeRabbit configuration file

{adapters/**,examples/**}: Review adapter and example changes for command correctness, config/schema consistency, artifact handling, and compatibility with the public Fabric contracts.

Files:

  • adapters/common/src/nemo_fabric_adapters/common/utils.py
  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
🧠 Learnings (1)
📚 Learning: 2026-07-09T22:28:51.689Z
Learnt from: AjayThorve
Repo: NVIDIA/NeMo-Fabric PR: 43
File: adapters/claude-sdk/src/nemo_fabric_adapters/claude_sdk/adapter.py:164-168
Timestamp: 2026-07-09T22:28:51.689Z
Learning: In the NeMo-Fabric adapters, treat path values used in Fabric adapter configuration (including logic like `_resolve_path` in adapter.py) as config-root-relative. Do not apply `Path.expanduser()` (or otherwise apply `~`/home or shell-style expansion), because it will make the resolved paths normalize inconsistently across adapters. Also, do not rely on or add any resolution behavior that uses `harness.settings.cwd` as an override point for these adapter paths—`harness.settings.cwd` is explicitly unsupported in this adapter context.

Applied to files:

  • adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py
🧬 Code graph analysis (2)
adapters/common/src/nemo_fabric_adapters/common/utils.py (1)
tests/adapters/test_adapaters_common_utils.py (1)
  • write_relay_configs (666-666)
adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py (1)
adapters/common/src/nemo_fabric_adapters/common/utils.py (2)
  • runtime_id (99-105)
  • runtime_context (95-96)
🪛 Ruff (0.15.21)
adapters/common/src/nemo_fabric_adapters/common/utils.py

[warning] 675-677: Avoid specifying long messages outside the exception class

(TRY003)

🔇 Additional comments (3)
tests/adapters/test_adapaters_common_utils.py (1)

750-753: Use os.environ for environment changes.

This repeats the existing environment-mutation issue: use os.environ.pop("FABRIC_RELAY_CONFIG_PATH", None) instead of monkeypatch.delenv(...), then remove the fixture parameter.

Source: Coding guidelines

adapters/common/src/nemo_fabric_adapters/common/utils.py (1)

667-680: 🎯 Functional Correctness

Normalize returned configuration paths before subprocess use.

If config_directory is relative, the files are written relative to the parent process, while prepare_relay_cli_launch later invokes Relay with cwd=config_root. This can make --config point to a different location. Normalize the directory here or enforce absolute paths at every caller.

Suggested fix
         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"
+            config_dir = (Path(config_path).parent / "relay-config").resolve()
         else:
-            config_dir = Path(config_directory)
+            config_dir = Path(config_directory).resolve()
tests/adapters/test_hermes_dual_mode.py (1)

210-212: LGTM!

Comment on lines +299 to +304
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Constrain invocation_id before using it as a path.

An absolute or traversal-containing value such as /tmp/shared or ../../shared escapes hermes_home / relay-cli, allowing Relay configuration and XDG state to be written outside the intended invocation sandbox. Reject unsafe IDs or verify the resolved path remains beneath the isolation root, and add a traversal regression test.

Suggested containment check
     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
+    relay_root = (hermes_home / "relay-cli").resolve()
+    isolation_root = (relay_root / invocation_id).resolve()
+    try:
+        isolation_root.relative_to(relay_root)
+    except ValueError as exc:
+        raise ValueError(
+            "runtime_context.invocation_id escapes the Relay isolation root"
+        ) from exc
     isolation_root.mkdir(parents=True, exist_ok=True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
invocation_id = str(
common_utils.runtime_context(payload).get("invocation_id")
or common_utils.runtime_id(payload)
)
relay_root = (hermes_home / "relay-cli").resolve()
isolation_root = (relay_root / invocation_id).resolve()
try:
isolation_root.relative_to(relay_root)
except ValueError as exc:
raise ValueError(
"runtime_context.invocation_id escapes the Relay isolation root"
) from exc
isolation_root.mkdir(parents=True, exist_ok=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adapters/hermes/src/nemo_fabric_adapters/hermes/adapter.py` around lines 299
- 304, Constrain the invocation_id derived in the adapter flow before
constructing isolation_root, rejecting absolute paths and traversal components
or validating the resolved path remains beneath hermes_home / relay-cli.
Preserve valid invocation IDs and ensure unsafe values cannot create directories
or write state outside the sandbox; add a regression test covering absolute and
parent-directory traversal IDs.

Comment on lines +756 to +763
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert that the isolated files are actually written.

The test checks only returned path values; it would pass even if either file were not created. Add file-existence or content assertions to cover the new output-directory behavior.

Suggested assertions
     assert relay_path == config_directory / "config.toml"
     assert plugin_path == config_directory / "plugins.toml"
+    assert relay_path is not None and relay_path.is_file()
+    assert plugin_path is not None and plugin_path.is_file()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"
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"
assert relay_path is not None and relay_path.is_file()
assert plugin_path is not None and plugin_path.is_file()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/adapters/test_adapaters_common_utils.py` around lines 756 - 763, Extend
the test around common_utils.write_relay_configs to verify that both returned
paths, relay_path and plugin_path, correspond to files written in
config_directory. Add existence or content assertions for config.toml and
plugins.toml while preserving the existing path-value assertions.

Source: Path instructions

@bbednarski9 bbednarski9 closed this by deleting the head repository Jul 23, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Jul 24, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant