Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci_python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,5 @@ jobs:
- name: Run pytest
run: |
set -euo pipefail
uv sync --group test --no-group dev
uv sync --group test --no-group dev --extra hermes
uv run pytest
32 changes: 32 additions & 0 deletions adapters/common/src/nemo_fabric_adapters/common/hermes.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ def runtime_context(payload: dict[str, Any]) -> dict[str, Any]:
return payload.get("runtime_context") or {}


def runtime_session_id(payload: dict[str, Any]) -> str | None:
runtime_id = runtime_context(payload).get("runtime_id")
if runtime_id:
return str(runtime_id)
return None


def request_payload(payload: dict[str, Any]) -> dict[str, Any]:
return payload.get("request") or {}

Expand Down Expand Up @@ -325,3 +332,28 @@ def collect_relay_artifacts(plugin_config: dict[str, Any]) -> list[dict[str, str
for path in sorted(directory.glob(pattern)):
artifacts.append({"kind": section_name, "path": str(path)})
return artifacts

def ensure_hermes_session(fabric_runtime_id: str, model_name: str, model_config: dict[str, Any], hermes_home: Path) -> dict[str, Any]:
"""
Ensure that a session exists in the Hermes session database for the given fabric_runtime_id.
If the session does not exist, it will be created.

When creating a new session, Hermes allows us to provide our own session_id (as long as it's unique), which for
convenience will be set to the fabric_runtime_id.

However when Hermes compresses a session, it will return a new session_id, so we can't depend on the
fabric_runtime_id being the same as the session_id after a session has been compressed.

However looking up a session by title will always return the most recent session, so after creating the session
we will set the title to the fabric_runtime_id, and then we can always look up the session by title.
"""
from hermes_state import SessionDB

session_db = SessionDB(db_path=hermes_home / "state.db")
session = session_db.get_session_by_title(fabric_runtime_id)
if session is None:
session_db.ensure_session(fabric_runtime_id, source="fabric", model=model_name, model_config=model_config)
session_db.set_session_title(session_id=fabric_runtime_id, title=fabric_runtime_id)
session = session_db.get_session_by_title(fabric_runtime_id)

return session
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,26 @@ def _api_key_preflight_check(settings: dict[str, Any], model_config: dict[str, A
) from exc


def get_runtime_mode(payload: dict[str, Any]) -> str:
runtime = hermes_common.fabric_config(payload).get("runtime") or {}
return runtime.get("mode", "oneshot")


def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
settings = hermes_common.settings_payload(payload)
request = hermes_common.request_payload(payload)
config_root = Path(hermes_common.config_root(payload)).resolve()
environment = hermes_common.environment_payload(payload)
model_config = hermes_common.selected_model_config(payload)
model_name = settings.get("model_name") or model_config.get("model")
runtime_mode = get_runtime_mode(payload)
use_session = runtime_mode == "session"
fabric_runtime_id = hermes_common.runtime_session_id(payload)

relay_plugin_config = hermes_common.configure_hermes_relay(payload)

_api_key_preflight_check(settings, model_config)

model_name = settings.get("model_name") or model_config.get("model")

hermes_home = resolve_path(
config_root,
settings.get("hermes_home", "./artifacts/hermes-cli/home"),
Expand All @@ -79,15 +86,26 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
relay_enabled=relay_plugin_config is not None,
)

if use_session:
if fabric_runtime_id is None:
raise RuntimeError(
"runtime.mode=session is set, but no runtime_id was provided in the payload. "
"Please provide a runtime_id to resume an existing session."
)
hermes_common.ensure_hermes_session(fabric_runtime_id, model_name, model_config, hermes_home)

prompt = request_to_prompt(request)
toolsets = hermes_common.normalize_list(settings.get("enabled_toolsets"))

command = build_command(
settings,
config_root,
model_config,
model_name,
prompt,
toolsets=toolsets,
use_session=use_session,
fabric_runtime_id=fabric_runtime_id,
)
cwd = resolve_path(
config_root,
Expand All @@ -104,24 +122,33 @@ def run_hermes_cli(payload: dict[str, Any]) -> dict[str, Any]:
check=False,
)

# When use_session is True, session_id will be printed to stderr
response = completed.stdout.strip()
stderr_output = completed.stderr.strip()
return_code = completed.returncode
if return_code != 0:
error_message = stderr_output or f"hermes CLI exited with return code {return_code}"
else:
error_message = None

output = {
"harness": "hermes",
"adapter": "cli",
"base_url": hermes_common.get_base_url(settings, model_config),
"mode": "hermes_cli_oneshot",
"mode": f"hermes_cli_{runtime_mode}",
"command": redact_command(command),
"cwd": str(cwd),
"enabled_toolsets": toolsets,
"error": completed.stderr or None,
"error": error_message,
"fabric_home": os.environ.get("FABRIC_HOME"),
"fabric_invocation": os.environ.get("FABRIC_INVOCATION"),
"model": model_name,
"returncode": completed.returncode,
"returncode": return_code,
"response": response,
"session_id": fabric_runtime_id,
"stdout": completed.stdout,
"stderr": completed.stderr,
"failed": completed.returncode != 0,
"failed": return_code != 0,
"hermes_home": str(hermes_home),
"hermes_config_path": str(hermes_config_path),
"hermes_native_config": hermes_common.summarize_hermes_config(hermes_config),
Expand All @@ -146,6 +173,8 @@ def build_command(
model_name: str | None,
prompt: str,
toolsets: list[str] | None = None,
use_session: bool = False,
fabric_runtime_id: str | None = None,
) -> list[str]:
command = resolve_command(
config_root,
Expand All @@ -154,7 +183,14 @@ def build_command(
command_args = hermes_common.normalize_list(settings.get("hermes_args") or settings.get("command_args"))
provider = settings.get("provider") or model_config.get("provider")

args = [command, *command_args, "-z", prompt]
args = [command, *command_args]
if use_session:
# On the first invocation, we create the session up-front, and use the `--continue` flag to resume it even
# though technically it's an empty session.
args.extend(["chat", "--quiet", "--continue", fabric_runtime_id, "--query", prompt])
else:
args.extend(["-z", prompt])

if model_name:
args.extend(["--model", str(model_name)])
if provider and settings.get("pass_provider_flag", True):
Expand Down Expand Up @@ -208,7 +244,7 @@ def redact_command(command: list[str]) -> list[str]:
redacted.append("<redacted>")
else:
redacted.append(arg)
if arg in {"-z", "--oneshot"}:
if arg in {"-z", "--oneshot", "--query"}:
redact_next = True
return redacted

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,6 @@ def resolve_hermes_toolsets(settings: dict[str, Any], config: dict[str, Any]) ->
return sorted(_get_platform_tools(config, platform))


def runtime_session_id(payload: dict[str, Any]) -> str | None:
runtime_id = hermes_common.runtime_context(payload).get("runtime_id")
if runtime_id:
return str(runtime_id)
return None


def load_runtime_history(session_db: Any, session_id: str | None) -> list[dict[str, Any]] | None:
if not session_id:
return None
Expand Down Expand Up @@ -120,7 +113,7 @@ def run_hermes_sdk(payload: dict[str, Any]) -> dict[str, Any]:
discover_plugins(force=True)
loaded_hermes_config = load_config()
enabled_toolsets = resolve_hermes_toolsets(settings, loaded_hermes_config)
session_id = runtime_session_id(payload)
session_id = hermes_common.runtime_session_id(payload)
session_db = SessionDB()
conversation_history = load_runtime_history(session_db, session_id)
agent = None
Expand Down
34 changes: 34 additions & 0 deletions examples/code-review-agent/profiles/hermes-cli-session.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

schema_version: fabric.profile/v1alpha1
name: hermes_cli_session
description: Drive the Hermes CLI adapter as a multi-turn session (runtime mode session).

harness:
adapter_id: nvidia.fabric.hermes.cli
resolution: preinstalled
settings:
python_env: HERMES_PYTHON
workspace: ./repos/my-service
hermes_home: ./artifacts/hermes-home
base_url: https://integrate.api.nvidia.com/v1
max_iterations: 1
terminal_timeout: 60
enabled_toolsets: []
system_prompt: You are a concise smoke test assistant.

runtime:
mode: session
transport: cli
input_schema: chat
output_schema: message
artifacts: ./artifacts/hermes-cli-session

environment:
provider: local
workspace: ./repos/my-service
artifacts: ./artifacts/hermes-cli-session

telemetry:
enabled: false
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def restore_environ_fixture():
if key not in orig_vars:
del os.environ[key]

@pytest.fixture(name="repo_root", scope="session")
def repo_root_fixture() -> Path:
return CUR_DIR.parent.resolve()

@pytest.fixture(name="hermes_cli_agent_dir_src", scope="session")
def hermes_cli_agent_dir_fixture() -> Path:
agent_dir = CUR_DIR / "fixtures" / "hermes-cli-agent"
Expand All @@ -35,6 +39,10 @@ def hermes_cli_agent_dir_fixture() -> Path:

@pytest.fixture(name="hermes_agent_dir")
def hermes_agent_dir_fixture(hermes_cli_agent_dir_src: Path, tmp_path: Path) -> Path:
"""
Creates a temporary copy of the fake Hermes CLI agent directory for testing.
This mirrors the behavior of the smoke tests.
"""
agent_dir = tmp_path / "hermes-cli-agent"
shutil.copytree(hermes_cli_agent_dir_src, agent_dir)
assert agent_dir.exists(), f"Missing fake Hermes CLI agent directory: {agent_dir}"
Expand All @@ -44,6 +52,13 @@ def hermes_agent_dir_fixture(hermes_cli_agent_dir_src: Path, tmp_path: Path) ->
def hermes_cli_profile_fixture() -> str:
return "env_local"

@pytest.fixture(name="hermes_cli_session_profile")
def hermes_cli_session_profile_fixture(repo_root: Path, hermes_agent_dir: Path) -> str:
src_yaml = repo_root / "examples/code-review-agent/profiles/hermes-cli-session.yaml"
assert src_yaml.exists(), f"Missing hermes-cli-session.yaml profile: {src_yaml}"
shutil.copy(src_yaml, hermes_agent_dir / "profiles/hermes-cli-session.yaml")
return "hermes_cli_session"


@pytest.fixture(name="hermes_command")
def hermes_command_fixture(hermes_agent_dir: Path) -> Path:
Expand All @@ -70,3 +85,14 @@ def adapters_common_fixture(adapters_common_src_dir: Path) -> str:
def hermes_common_fixture(adapters_common: str) -> types.ModuleType:
import nemo_fabric_adapters.common.hermes as hermes_common # noqa: E402
return hermes_common

@pytest.fixture(name="hermes_state", scope="session")
def require_hermes_state_fixture() -> types.ModuleType:
"""
Fixture to ensure that the hermes_state module is available for tests that require it.
"""
try:
import hermes_state
return hermes_state
except ImportError:
pytest.skip("Skipping test because hermes-agent is not installed.")
49 changes: 47 additions & 2 deletions tests/smoke_hermes_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"""Opt-in integration smoke for the SDK multi-turn Session path (real Hermes).

Drives ``FabricClient.start -> invoke -> invoke -> stop`` against the Hermes SDK
adapter and asserts the session carries conversation memory across turns through
the same Fabric runtime handle.
and CLI adapters and asserts the session carries conversation memory across
turns through the same Fabric runtime handle.

Unlike ``smoke_hermes_sdk.py`` (which shells out to the CLI), the session path is
SDK-only and runs through the native Fabric runtime lifecycle, so this must be
Expand Down Expand Up @@ -46,10 +46,32 @@ def main() -> None:
"venv python (set HERMES_PYTHON or invoke it directly)"
)
return
hermes_state_spec = importlib.util.find_spec("hermes_state")
if hermes_state_spec is None:
print(
"skipping: Hermes session state (hermes_state) is not importable; run "
"with the Hermes venv python"
)
return
hermes_state_origin = hermes_state_spec.origin
if hermes_state_origin:
hermes_site_packages = str(Path(hermes_state_origin).resolve().parent)
os.environ["PYTHONPATH"] = (
f"{hermes_site_packages}{os.pathsep}{os.environ['PYTHONPATH']}"
if os.environ.get("PYTHONPATH")
else hermes_site_packages
)
python_bin = Path(sys.executable).resolve().parent
os.environ["PATH"] = f"{python_bin}{os.pathsep}{os.environ.get('PATH', '')}"
asyncio.run(_run())


async def _run() -> None:
await _run_sdk_session()
await _run_cli_session()


async def _run_sdk_session() -> None:
from nemo_fabric import FabricClient, SessionStatus

agent = str(ROOT / "examples" / "code-review-agent")
Expand All @@ -71,6 +93,29 @@ async def _run() -> None:
assert "robin" in response, response

assert session.status is SessionStatus.STOPPED, session.status


async def _run_cli_session() -> None:
from nemo_fabric import FabricClient, SessionStatus

agent = str(ROOT / "examples" / "code-review-agent")
async with await FabricClient().start(agent, profile="hermes_cli_session") as session:
assert session.status is SessionStatus.ACTIVE, session.status

r1 = await session.invoke("My name is Robin. Please remember it for later.")
assert r1["status"] == "succeeded", r1
assert r1["output"]["mode"] == "hermes_cli_session", r1
assert r1["output"]["session_id"] == session.runtime_id, r1

r2 = await session.invoke("What is my name? Reply with just the name.")
assert r2["status"] == "succeeded", r2
assert r2["runtime_id"] == r1["runtime_id"], (r1, r2)
assert r2["output"]["session_id"] == r1["output"]["session_id"], (r1, r2)

response = (r2["output"].get("response") or "").lower()
assert "robin" in response, response

assert session.status is SessionStatus.STOPPED, session.status
print("smoke_hermes_session ok")


Expand Down
26 changes: 26 additions & 0 deletions tests/test_hermes_cli_fields.py → tests/test_hermes_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import types
from pathlib import Path

from nemo_fabric import FabricClient
Expand Down Expand Up @@ -33,3 +34,28 @@ async def test_hermes_cli_fields(hermes_command: Path, hermes_agent_dir: Path, h
for field in ('base_url', 'enabled_toolsets', 'error', 'response'):
# Ensure these fields are present in the output, even if they are None
assert field in output, f"Missing field in output: {field}"


async def test_hermes_cli_multi_turn(hermes_agent_dir: Path, hermes_cli_session_profile: str, hermes_state: types.ModuleType):
"""
Test that multi-turn sessions are tracked in the hermes session database when using the hermes_cli adapter.

This test calls the fake-hermes.py script rather than hermes itself, thus it doesn't require an API key, however
the hermes_cli adapter does use the hermes_state module, so we can test that the session is recorded propperly.
"""
async with await FabricClient().start(hermes_agent_dir,
profile=hermes_cli_session_profile) as session:
runtime_id = session.runtime["runtime_id"]
await session.invoke("prompt1")
await session.invoke("prompt2")

session_db_path = hermes_agent_dir / "artifacts/hermes-home/state.db"
assert session_db_path.exists(), f"Expected session DB at {session_db_path} does not exist"

session_db = hermes_state.SessionDB(db_path=session_db_path)
session = session_db.get_session_by_title(runtime_id)
assert session is not None
assert session['id'] == runtime_id
assert session['model'] == 'test-model'
assert session['source'] == 'fabric'
assert session['title'] == runtime_id