Skip to content
Open
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
6 changes: 6 additions & 0 deletions benchmark/agent-versions.env
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ CLAUDE_CODE_VERSION=2.1.211
CODEX_VERSION=0.144.5
OPENCODE_VERSION=1.18.3
NODE_VERSION=20.11.1
# Hermes (NousResearch hermes-agent), installed from GitHub at dataset-bake time.
# Must be an immutable ref — a tag or commit SHA. A moving ref such as "main" is
# rejected at build time: the run manifest records this value as a pin, and two
# builds recording the same string while installing different code is worse than
# recording nothing. The installer script is fetched from this ref too.
HERMES_VERSION=v2026.8.3
102 changes: 102 additions & 0 deletions benchmark/patches/harbor-agent-patches.diff
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,105 @@
+++ b/harbor/switchyard_patch_id.txt
@@ -0,0 +1 @@
+switchyard-harbor-patches-2026-05-22-v2

--- a/harbor/agents/installed/hermes.py
+++ b/harbor/agents/installed/hermes.py
@@ -59,6 +59,16 @@
return 'export PATH="$HOME/.local/bin:$PATH"; hermes version'

async def install(self, environment: BaseEnvironment) -> None:
+ # Skip if hermes is already installed (e.g. from the dataset
+ # agent-bake layer). hermes installs to $HOME/.local/bin, so export
+ # that path before probing. Required for closed-book runs where the
+ # task-time curl install cannot reach github/pypi.
+ probe = await self.exec_as_agent(
+ environment,
+ command='export PATH="$HOME/.local/bin:$PATH"; command -v hermes >/dev/null 2>&1 && echo present || true',
+ )
+ if "present" in (getattr(probe, "stdout", "") or ""):
+ return
await self.exec_as_root(
environment,
command="apt-get update && apt-get install -y curl git ripgrep xz-utils",
@@ -92,11 +92,31 @@
# ------------------------------------------------------------------

@staticmethod
- def _build_config_yaml(model: str) -> str:
- """Generate a hermes config.yaml with full capabilities enabled."""
+ def _build_config_yaml(
+ model: str, base_url: str | None = None, api_key: str | None = None
+ ) -> str:
+ """Generate a hermes config.yaml with full capabilities enabled.
+
+ When ``base_url`` is given, route the model through a custom
+ OpenAI-compatible endpoint (e.g. a Switchyard gateway): hermes only
+ honors a custom endpoint via its config.yaml ``model.provider=custom`` +
+ ``model.base_url`` (the ``OPENAI_BASE_URL`` env var is ignored for the
+ chat model), so we nest the provider/base_url/api_key under ``model``.
+ """
+ model_field: Any
+ if base_url:
+ model_field = {
+ "default": model,
+ "provider": "custom",
+ "base_url": base_url,
+ }
+ if api_key:
+ model_field["api_key"] = api_key
+ else:
+ model_field = model
config: dict[str, Any] = {
- "model": model,
- "provider": "auto",
+ "model": model_field,
+ **({} if base_url else {"provider": "auto"}),
"toolsets": ["hermes-cli"],
"agent": {"max_turns": 90},
"memory": {
@@ -357,6 +377,9 @@
# Try native provider key first, fall back to OpenRouter.
hermes_provider_flag: str | None = None
use_native = False
+ # Custom OpenAI-compatible endpoint (e.g. Switchyard) for the chat model.
+ custom_base_url: str | None = None
+ custom_api_key: str | None = None

if provider in _NATIVE_PROVIDERS:
native_flag, key_names = _NATIVE_PROVIDERS[provider]
@@ -367,11 +390,15 @@
hermes_provider_flag = native_flag
use_native = True
break
- # Forward OPENAI_BASE_URL when using native OpenAI key
+ # Forward OPENAI_BASE_URL when using native OpenAI key. Hermes ignores
+ # this env var for the chat model, so also thread it into config.yaml
+ # as a custom provider (see _build_config_yaml).
if use_native and provider == "openai":
base_url = os.environ.get("OPENAI_BASE_URL")
if base_url:
env["OPENAI_BASE_URL"] = base_url
+ custom_base_url = base_url
+ custom_api_key = os.environ.get("OPENAI_API_KEY")

if not use_native:
openrouter_key = os.environ.get("OPENROUTER_API_KEY")
@@ -386,9 +413,15 @@
env["OPENROUTER_API_KEY"] = openrouter_key

# Native providers with --provider flag use just the model name;
- # everything else (OpenRouter, openai direct) uses provider/model.
- cli_model = model if hermes_provider_flag else self.model_name
- config_yaml = self._build_config_yaml(cli_model)
+ # a custom OpenAI-compatible endpoint also uses the bare model (the
+ # base_url already targets the gateway); everything else uses provider/model.
+ if custom_base_url:
+ cli_model = model
+ else:
+ cli_model = model if hermes_provider_flag else self.model_name
+ config_yaml = self._build_config_yaml(
+ cli_model, base_url=custom_base_url, api_key=custom_api_key
+ )

# Pass instruction via env var (safe from shell escaping issues)
env["HARBOR_INSTRUCTION"] = instruction
32 changes: 31 additions & 1 deletion benchmark/prepare_harbor_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,27 @@ def _install_layer(pins: dict[str, str]) -> str:
claude_version = pins["CLAUDE_CODE_VERSION"]
codex_version = pins["CODEX_VERSION"]
opencode_version = pins["OPENCODE_VERSION"]
# Hermes (NousResearch hermes-agent) is a per-user uv app installed from
# GitHub, not an npm package. Baking it here (build-time, with host network)
# means the runtime install() skip-guard short-circuits, so tasks need no
# egress for it — enabling closed-book Hermes runs.
#
# HERMES_VERSION must name an immutable ref. A moving ref would let two builds
# record the same version string while installing different code, which is worse
# than not recording it: the manifest would assert a reproducibility it does not
# have. The installer script is fetched from the same ref for the same reason —
# pinning the agent but running whatever installer main has today reintroduces
# exactly the drift the pin exists to prevent.
hermes_version = pins["HERMES_VERSION"]
if hermes_version in ("main", "master", "HEAD"):
Comment on lines +201 to +202

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

Require HERMES_VERSION before indexing pins.

Line 201 raises KeyError when HERMES_VERSION is absent. prepare_dataset validates only the four older pins at Line 496. Add HERMES_VERSION to required so preparation fails with the existing clear validation error.

Proposed fix
-    required = {"CLAUDE_CODE_VERSION", "CODEX_VERSION", "OPENCODE_VERSION", "NODE_VERSION"}
+    required = {
+        "CLAUDE_CODE_VERSION",
+        "CODEX_VERSION",
+        "HERMES_VERSION",
+        "NODE_VERSION",
+        "OPENCODE_VERSION",
+    }
🤖 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 `@benchmark/prepare_harbor_dataset.py` around lines 201 - 202, Add
"HERMES_VERSION" to the required pin names validated by prepare_dataset before
the pins["HERMES_VERSION"] access, preserving the existing validation error
behavior for missing pins.

raise SystemExit(
f"HERMES_VERSION={hermes_version!r} is a moving ref and cannot be recorded "
"as a reproducible pin; use a tag or commit SHA"
)
Comment on lines +195 to +206

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

Use a full commit SHA for the Hermes install source.

A Git tag can be deleted or repointed. The current validation also accepts other moving branch names. The same manifest value can therefore install different Hermes code.

  • benchmark/prepare_harbor_dataset.py#L195-L206: require a full commit SHA, or resolve the configured ref to a commit SHA before generating the Docker layer and manifest.
  • benchmark/agent-versions.env#L8-L13: replace v2026.8.3 with the resolved full commit SHA.
  • tests/test_prepare_harbor_dataset.py#L331-L346: update the fixture to use a full SHA and reject tag and branch references.
📍 Affects 3 files
  • benchmark/prepare_harbor_dataset.py#L195-L206 (this comment)
  • benchmark/agent-versions.env#L8-L13
  • tests/test_prepare_harbor_dataset.py#L331-L346
🤖 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 `@benchmark/prepare_harbor_dataset.py` around lines 195 - 206, Require
HERMES_VERSION in the prepare_harbor_dataset validation flow to be a full commit
SHA, or resolve configured refs to one before generating the Docker layer and
manifest; reject tags and branch references. In
benchmark/prepare_harbor_dataset.py lines 195-206, update the HERMES_VERSION
validation accordingly; in benchmark/agent-versions.env lines 8-13, replace
v2026.8.3 with the resolved full SHA; in tests/test_prepare_harbor_dataset.py
lines 331-346, use a full-SHA fixture and assert that tag and branch references
are rejected.

return f"""

# Switchyard benchmark prebaked coding agents.
ENV SWITCHYARD_PREBAKED_AGENT_VERSIONS="claude-code={claude_version},codex={codex_version},opencode={opencode_version},node={node_version}"
ENV SWITCHYARD_PREBAKED_AGENT_VERSIONS="claude-code={claude_version},codex={codex_version},opencode={opencode_version},node={node_version},hermes={hermes_version}"
RUN set -eux; \\
if command -v apt-get >/dev/null 2>&1; then \\
apt-get update; \\
Expand Down Expand Up @@ -231,6 +248,19 @@ def _install_layer(pins: dict[str, str]) -> str:
claude --version; \\
codex --version; \\
opencode --version
RUN set -eux; \\
export HOME=/root; \\
export PATH="/root/.local/bin:$PATH"; \\
if command -v apt-get >/dev/null 2>&1; then \\
apt-get update; \\
apt-get install -y --no-install-recommends git ripgrep xz-utils; \\
rm -rf /var/lib/apt/lists/*; \\
elif command -v apk >/dev/null 2>&1; then \\
apk add --no-cache git ripgrep xz; \\
fi; \\
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/{hermes_version}/scripts/install.sh \\
| bash -s -- --skip-setup --branch {hermes_version}; \\
Comment on lines +258 to +262

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 | 🟠 Major | ⚡ Quick win

Install Bash in the Alpine branch.

The APK branch installs git, ripgrep, and xz, but Line 262 pipes the installer into bash. Alpine does not include Bash by default. Hermes image builds on Alpine fail before installation.

Proposed fix
-        apk add --no-cache git ripgrep xz; \
+        apk add --no-cache bash git ripgrep xz; \
📝 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
elif command -v apk >/dev/null 2>&1; then \\
apk add --no-cache git ripgrep xz; \\
fi; \\
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/{hermes_version}/scripts/install.sh \\
| bash -s -- --skip-setup --branch {hermes_version}; \\
elif command -v apk >/dev/null 2>&1; then \\
apk add --no-cache bash git ripgrep xz; \\
fi; \\
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/{hermes_version}/scripts/install.sh \\
| bash -s -- --skip-setup --branch {hermes_version}; \\
🤖 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 `@benchmark/prepare_harbor_dataset.py` around lines 258 - 262, Update the
Alpine package installation branch in the generated setup command to include the
bash package alongside git, ripgrep, and xz, ensuring the subsequent installer
pipeline to bash succeeds on Alpine.

hermes version
"""


Expand Down
38 changes: 38 additions & 0 deletions tests/test_prepare_harbor_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from types import ModuleType

import pytest
import yaml

REPO = Path(__file__).resolve().parents[1]
Expand Down Expand Up @@ -284,6 +285,7 @@ def test_generated_dataset_manifest_records_pins_tasks_and_digests(tmp_path: Pat
assert manifest["agent_versions"] == {
"CLAUDE_CODE_VERSION": "2.1.211",
"CODEX_VERSION": "0.144.5",
"HERMES_VERSION": "v2026.8.3",
"NODE_VERSION": "20.11.1",
"OPENCODE_VERSION": "1.18.3",
}
Expand All @@ -306,3 +308,39 @@ def test_generated_compose_bakes_task_id_into_proxy_env(tmp_path: Path) -> None:
proxy_env = "\n".join(compose["services"]["proxy"]["environment"])
assert "SWITCHYARD_TASK_ID=task-id-check" in proxy_env
assert "SWITCHYARD_TRIAL_DIR=${HOST_AGENT_LOGS_PATH:-}" in proxy_env

def test_a_moving_hermes_ref_is_rejected() -> None:
"""A moving ref cannot be recorded as a pin.

The dataset manifest presents HERMES_VERSION as a reproducibility guarantee. Two
builds recording the same string while installing different Hermes code is worse
than recording nothing, so the build fails rather than asserting a pin it does not
have.
"""
base = {
"CLAUDE_CODE_VERSION": "1",
"CODEX_VERSION": "2",
"OPENCODE_VERSION": "3",
"NODE_VERSION": "4",
}
for moving in ("main", "master", "HEAD"):
with pytest.raises(SystemExit, match="moving ref"):
_load_generator_module()._install_layer({**base, "HERMES_VERSION": moving})


def test_the_hermes_installer_is_fetched_at_the_pinned_ref() -> None:
"""Pinning the agent but running main's installer reintroduces the same drift."""
pins = {
"CLAUDE_CODE_VERSION": "1",
"CODEX_VERSION": "2",
"OPENCODE_VERSION": "3",
"NODE_VERSION": "4",
"HERMES_VERSION": "v2026.8.3",
}

layer = _load_generator_module()._install_layer(pins)

assert "hermes-agent/v2026.8.3/scripts/install.sh" in layer
assert "hermes-agent/main/" not in layer
assert "--branch v2026.8.3" in layer