Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45d964ebb8 | |||
| ecd36e562b | |||
| 078bf57f09 | |||
| 476a78602b | |||
| 117fbf3ff5 | |||
| 9e78e64ecf | |||
| 025f6f1b39 | |||
| 100125d022 | |||
| 34dea55f82 | |||
| 3f43efeb3f | |||
| 2186f148cc | |||
| d12f7a273d |
@@ -49,7 +49,7 @@ resolved from the YAML file's directory.
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, copilot, ...
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, pi, antigravity, qwen, copilot, hermes, ...
|
||||
model: databricks-claude-opus-4-7
|
||||
auth:
|
||||
type: databricks
|
||||
|
||||
@@ -9465,6 +9465,64 @@ def _manage_goose_harness() -> None:
|
||||
status = None
|
||||
|
||||
|
||||
def _manage_hermes_harness() -> None:
|
||||
"""Run the level-2 loop for Hermes: ensure the CLI is installed.
|
||||
|
||||
Hermes owns its own auth via ``hermes model`` (interactive provider/model
|
||||
picker) and is installed via a curl script from Nous Research — Omnigent
|
||||
stores no Hermes credential. A missing CLI gates the drill-in; when
|
||||
installed, the drill-in offers to launch ``hermes model`` for provider
|
||||
configuration.
|
||||
|
||||
:returns: None. Side effects: may launch ``hermes model``.
|
||||
"""
|
||||
from omnigent.onboarding.harness_install import (
|
||||
HERMES_KEY,
|
||||
harness_cli_installed,
|
||||
harness_install_spec,
|
||||
)
|
||||
from omnigent.onboarding.interactive import console, select
|
||||
|
||||
if not harness_cli_installed(HERMES_KEY):
|
||||
spec = harness_install_spec(HERMES_KEY)
|
||||
hint = (
|
||||
spec.install_hint
|
||||
if spec and spec.install_hint
|
||||
else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
)
|
||||
console.print(
|
||||
f" Hermes isn't installed. Install it with:\n [bold]{hint}[/bold]\n"
|
||||
" then re-open this menu."
|
||||
)
|
||||
return
|
||||
|
||||
status: str | None = None
|
||||
while True:
|
||||
rows: list[_HarnessMenuRow] = [
|
||||
_HarnessMenuRow("Run hermes model (configure provider)", action="model"),
|
||||
_HarnessMenuRow("← Back", action="back"),
|
||||
]
|
||||
idx = select(
|
||||
"Hermes Agent",
|
||||
[r.label for r in rows],
|
||||
clear_on_exit=True,
|
||||
status=status,
|
||||
)
|
||||
if idx < 0:
|
||||
return
|
||||
action = rows[idx].action
|
||||
if action == "back":
|
||||
return
|
||||
if action == "model":
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
subprocess.run(["hermes", "model"], check=False)
|
||||
status = "✓ hermes model completed"
|
||||
except FileNotFoundError:
|
||||
status = "✗ hermes binary not found"
|
||||
|
||||
|
||||
def _prompt_install_copilot() -> str | None:
|
||||
"""Offer to install the missing ``copilot`` extra; return a status line.
|
||||
|
||||
@@ -10183,6 +10241,7 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
COPILOT_KEY,
|
||||
CURSOR_KEY,
|
||||
GOOSE_KEY,
|
||||
HERMES_KEY,
|
||||
OPENCODE_KEY,
|
||||
QWEN_KEY,
|
||||
harness_cli_installed,
|
||||
@@ -10240,6 +10299,9 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
# provider family (Goose owns its own auth via ``goose configure``, not an
|
||||
# Omnigent credential), so it dispatches to its own drill-in.
|
||||
_GOOSE = "\x00goose"
|
||||
# Sentinel marking the Hermes row — like Goose it owns its own auth via
|
||||
# ``hermes model`` and is installed via a curl installer.
|
||||
_HERMES = "\x00hermes"
|
||||
families = [ANTHROPIC_FAMILY, OPENAI_FAMILY, PI_SURFACE]
|
||||
while True:
|
||||
config = _load_global_config()
|
||||
@@ -10459,6 +10521,28 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
options.append(f" {copilot_sub}")
|
||||
selectable.append(False)
|
||||
row_target.append(None)
|
||||
# Hermes Agent (its own provider config via ``hermes model``, installed
|
||||
# via a curl installer from Nous Research — no npm package or Omnigent
|
||||
# credential).
|
||||
hermes_installed = harness_cli_installed(HERMES_KEY)
|
||||
options.append(f"{' ' if hermes_installed else '[red]✗[/] '}Hermes")
|
||||
selectable.append(True)
|
||||
row_target.append(_HERMES)
|
||||
if not hermes_installed:
|
||||
from rich.markup import escape as _rich_escape
|
||||
|
||||
hermes_spec = harness_install_spec(HERMES_KEY)
|
||||
hermes_hint = _rich_escape(
|
||||
hermes_spec.install_hint
|
||||
if hermes_spec and hermes_spec.install_hint
|
||||
else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
)
|
||||
hermes_sub = f"[dim]not installed — open to install ({hermes_hint})[/]"
|
||||
else:
|
||||
hermes_sub = "[green]✓[/] ready"
|
||||
options.append(f" {hermes_sub}")
|
||||
selectable.append(False)
|
||||
row_target.append(None)
|
||||
options.append("Quit")
|
||||
selectable.append(True)
|
||||
row_target.append(_QUIT)
|
||||
@@ -10485,6 +10569,8 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
_manage_opencode_harness()
|
||||
elif target == _GOOSE:
|
||||
_manage_goose_harness()
|
||||
elif target == _HERMES:
|
||||
_manage_hermes_harness()
|
||||
else: # Quit row (or, defensively, a non-family row)
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"""
|
||||
HermesExecutor: run agent turns through the Hermes Agent CLI.
|
||||
|
||||
Spawns ``hermes chat -q`` as a subprocess for each turn. Hermes manages its
|
||||
own session state via a persistent session store (SQLite under
|
||||
``~/.hermes/``), so the executor uses ``--resume <session_id>`` on subsequent
|
||||
turns to maintain conversational context across the Omnigent session without
|
||||
re-serialising the full history.
|
||||
|
||||
Each turn yields text output as ``TextChunk`` / ``TurnComplete`` events.
|
||||
Omnigent policies are enforced on Hermes' native tool calls via Hermes'
|
||||
``pre_tool_call`` shell hook mechanism: a per-session ``HERMES_HOME``
|
||||
directory is created with a ``config.yaml`` that registers a policy hook
|
||||
script, matching how Codex uses a per-session ``CODEX_HOME``.
|
||||
|
||||
Requirements:
|
||||
The ``hermes`` CLI must be installed and on PATH (or set via
|
||||
``HARNESS_HERMES_PATH``).
|
||||
|
||||
Env vars read at construction:
|
||||
|
||||
- ``HARNESS_HERMES_MODEL`` — model identifier, e.g. ``"deepseek/deepseek-chat"``
|
||||
or ``"anthropic/claude-sonnet-4"``. ``None`` falls back to Hermes' own
|
||||
configured default model.
|
||||
- ``HARNESS_HERMES_CWD`` — working directory the subprocess runs in.
|
||||
``None`` falls back to ``os.getcwd()``.
|
||||
- ``HARNESS_HERMES_PATH`` — absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``HARNESS_HERMES_OS_ENV`` — JSON-encoded :class:`OSEnvSpec`. When unset,
|
||||
defaults to ``caller_process + sandbox=none``.
|
||||
- ``HARNESS_HERMES_SKILLS_FILTER`` — JSON-encoded ``str | list[str]``
|
||||
carrying the agent spec's ``skills_filter``. When unset, falls back to
|
||||
``"all"``.
|
||||
- ``HARNESS_HERMES_BUNDLE_DIR`` — absolute path to the agent bundle's
|
||||
extracted root. Unset for agents without a bundled-skills directory.
|
||||
- ``HARNESS_HERMES_AGENT_NAME`` — agent display name (reserved for future use).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import (
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
Message,
|
||||
TextChunk,
|
||||
ToolSpec,
|
||||
TurnComplete,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum seconds to wait for a Hermes subprocess to complete a single turn.
|
||||
# Complex tasks (multi-tool-calling loops) may take several minutes.
|
||||
_HERMES_TURN_TIMEOUT_S = 600.0
|
||||
|
||||
# Regex to extract the session_id from Hermes' quiet-mode output line.
|
||||
# Matches lines like ``session_id: 20260620_142506_c51451``.
|
||||
_RE_SESSION_ID = re.compile(r"^session_id:\s+(\S+)")
|
||||
|
||||
# Regex to detect the resume notice line emitted by ``--resume``.
|
||||
# Matches lines like ``↻ Resumed session 20260620_142506_c51451 ...``.
|
||||
_RE_RESUME_NOTICE = re.compile(r"^↻\s+Resumed\s+session\s+\S+")
|
||||
|
||||
# Regex to detect the "continue" notice line.
|
||||
# Matches lines like ``↻ Resumed session NAME ...``.
|
||||
_RE_CONTINUE_NOTICE = re.compile(r"^↻\s+Resumed\s+session")
|
||||
|
||||
# Prefixes for Hermes warning/notice messages that should be stripped.
|
||||
_WARNING_PREFIXES = ("Warning:", "⚠")
|
||||
|
||||
|
||||
def _strip_hermes_metadata(output: str) -> str:
|
||||
r"""
|
||||
Strip Hermes metadata lines from subprocess stdout, leaving only
|
||||
the agent's response text.
|
||||
|
||||
Hermes' quiet mode (``-Q``) emits a small number of info lines
|
||||
alongside the actual response:
|
||||
|
||||
- ``session_id: <id>``
|
||||
- ``↻ Resumed session <id> ...``
|
||||
- ``Warning: ...``
|
||||
|
||||
:param output: Raw stdout from ``hermes chat -q``.
|
||||
:returns: The agent's response text with metadata lines removed.
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
filtered: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if _RE_SESSION_ID.match(stripped):
|
||||
continue
|
||||
if _RE_RESUME_NOTICE.match(stripped):
|
||||
continue
|
||||
if _RE_CONTINUE_NOTICE.match(stripped):
|
||||
continue
|
||||
if stripped.startswith(_WARNING_PREFIXES):
|
||||
continue
|
||||
filtered.append(line)
|
||||
return "\n".join(filtered).strip()
|
||||
|
||||
|
||||
def _parse_session_id(output: str) -> str | None:
|
||||
"""
|
||||
Extract the Hermes session ID from a subprocess response.
|
||||
|
||||
:param output: Raw stdout from ``hermes chat -q``.
|
||||
:returns: The session ID string, or ``None`` if no session_id
|
||||
line was found.
|
||||
"""
|
||||
for line in output.split("\n"):
|
||||
match = _RE_SESSION_ID.match(line.strip())
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_last_user_message(messages: list[Message]) -> str:
|
||||
"""
|
||||
Extract the text of the most recent user message from the
|
||||
Omnigent message list.
|
||||
|
||||
:param messages: The conversation message list passed to
|
||||
``run_turn``.
|
||||
:returns: The user message text, or ``""`` if none found.
|
||||
"""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
if parts:
|
||||
return "\n".join(parts)
|
||||
elif isinstance(content, str):
|
||||
return content
|
||||
return ""
|
||||
|
||||
|
||||
def _get_conversation_id() -> str | None:
|
||||
"""Extract the ``--conversation-id`` value from the CLI args.
|
||||
|
||||
The harness subprocess is launched by :mod:`process_manager` with
|
||||
``--conversation-id conv_<hex>`` on the command line.
|
||||
"""
|
||||
argv = sys.argv
|
||||
for i, arg in enumerate(argv):
|
||||
if arg == "--conversation-id" and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
# Keys from the user's ``~/.hermes/config.yaml`` that the per-session
|
||||
# HERMES_HOME needs in order to authenticate with the inference provider.
|
||||
# Everything else (secrets, security, agent tuning, terminal, etc.) is
|
||||
# either irrelevant to a headless Omnigent turn or actively harmful
|
||||
# (e.g. ``secrets.bitwarden`` referencing an unset ``BWS_ACCESS_TOKEN``).
|
||||
_USER_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"providers",
|
||||
"fallback_providers",
|
||||
"credential_pool_strategies",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _load_user_hermes_config() -> dict:
|
||||
"""Load inference-relevant keys from the user's ``~/.hermes/config.yaml``.
|
||||
|
||||
Returns a dict containing only the keys Hermes needs to resolve a
|
||||
model and authenticate (see :data:`_USER_CONFIG_KEYS`), or ``{}``
|
||||
when the file is missing or malformed.
|
||||
"""
|
||||
user_config = Path.home() / ".hermes" / "config.yaml"
|
||||
if not user_config.is_file():
|
||||
return {}
|
||||
try:
|
||||
import yaml
|
||||
|
||||
full = yaml.safe_load(user_config.read_text()) or {}
|
||||
return {k: v for k, v in full.items() if k in _USER_CONFIG_KEYS}
|
||||
except Exception: # noqa: BLE001 — catch YAML parse errors, permission errors, etc.
|
||||
_logger.debug("Failed to load user Hermes config at %s", user_config, exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _populate_hermes_home(
|
||||
hermes_home: Path,
|
||||
hook_script_path: str,
|
||||
server_url: str,
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""Populate a per-session ``HERMES_HOME`` with policy hook config.
|
||||
|
||||
Creates a ``config.yaml`` that registers the Omnigent policy hook
|
||||
as a ``pre_tool_call`` shell hook, and writes a wrapper script
|
||||
that exports the server env vars before exec-ing the Python hook.
|
||||
|
||||
The user's ``~/.hermes/config.yaml`` model/provider settings are
|
||||
merged into the per-session config so Hermes can authenticate with
|
||||
the inference provider the user configured via ``hermes model``.
|
||||
|
||||
This mirrors how Codex creates a per-session ``CODEX_HOME`` with
|
||||
its own ``config.toml`` — Hermes scopes all state (config, sessions,
|
||||
hooks, allowlist) to ``HERMES_HOME``.
|
||||
|
||||
:param hermes_home: The per-session HERMES_HOME directory.
|
||||
:param hook_script_path: Absolute path to ``hermes_policy_hook.py``.
|
||||
:param server_url: Omnigent server URL.
|
||||
:param session_id: Conversation / session ID for policy evaluation.
|
||||
"""
|
||||
hermes_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the wrapper shell script that sets env vars and execs the hook.
|
||||
wrapper = hermes_home / "omnigent-policy-hook.sh"
|
||||
wrapper.write_text(
|
||||
f"#!/bin/sh\n"
|
||||
f"export _OMNIGENT_SERVER_URL='{server_url}'\n"
|
||||
f"export _OMNIGENT_SESSION_ID='{session_id}'\n"
|
||||
f"exec '{sys.executable}' '{hook_script_path}'\n"
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
|
||||
# Start from the user's config so model/provider/auth settings carry over.
|
||||
# Hermes scopes everything to HERMES_HOME, so without this merge it won't
|
||||
# find the inference provider the user configured via ``hermes model``.
|
||||
user_cfg = _load_user_hermes_config()
|
||||
config: dict = {**user_cfg}
|
||||
|
||||
# Layer Omnigent's policy hook config on top.
|
||||
config["hooks_auto_accept"] = True
|
||||
config["hooks"] = {
|
||||
**config.get("hooks", {}),
|
||||
"pre_tool_call": [
|
||||
{
|
||||
"command": str(wrapper),
|
||||
# One day: must match the server's ``ask_timeout`` so
|
||||
# the hook stays alive while the human responds to the
|
||||
# web-UI approval card (ASK policy).
|
||||
"timeout": 86400,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
config_path = hermes_home / "config.yaml"
|
||||
# Use JSON for YAML-compatible output (JSON is valid YAML).
|
||||
config_path.write_text(json.dumps(config, indent=2) + "\n")
|
||||
|
||||
# Copy the user's .env file if present (carries API keys like
|
||||
# OPENROUTER_API_KEY, OPENAI_API_KEY, etc.).
|
||||
user_env = Path.home() / ".hermes" / ".env"
|
||||
if user_env.is_file():
|
||||
shutil.copy2(user_env, hermes_home / ".env")
|
||||
|
||||
# Copy the user's auth.json if present (carries provider credentials
|
||||
# stored by ``hermes auth`` / ``hermes model``).
|
||||
user_auth = Path.home() / ".hermes" / "auth.json"
|
||||
if user_auth.is_file():
|
||||
shutil.copy2(user_auth, hermes_home / "auth.json")
|
||||
|
||||
# Pre-populate the allowlist so Hermes never prompts for consent.
|
||||
# Hermes' allowlist format is {"approvals": [{"event": ..., "command": ...}]}.
|
||||
allowlist_path = hermes_home / "shell-hooks-allowlist.json"
|
||||
allowlist_data = {
|
||||
"approvals": [
|
||||
{"event": "pre_tool_call", "command": str(wrapper)},
|
||||
],
|
||||
}
|
||||
allowlist_path.write_text(json.dumps(allowlist_data, indent=2) + "\n")
|
||||
|
||||
|
||||
def _build_hermes_args(
|
||||
hermes_path: str,
|
||||
message: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build the argument list for a Hermes subprocess call.
|
||||
|
||||
:param hermes_path: Path to the Hermes CLI binary.
|
||||
:param message: The user message text.
|
||||
:param model: Optional model override (``-m`` flag).
|
||||
:param session_id: Optional session ID to resume (``--resume``).
|
||||
:returns: A list of CLI arguments.
|
||||
"""
|
||||
args = [
|
||||
hermes_path,
|
||||
"chat",
|
||||
"-q",
|
||||
message,
|
||||
"-Q", # quiet mode: suppress banner, spinner, tool previews
|
||||
"--source",
|
||||
"tool", # tag sessions as tool/integration-originated
|
||||
]
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if session_id:
|
||||
args.extend(["--resume", session_id])
|
||||
return args
|
||||
|
||||
|
||||
class HermesExecutor(Executor):
|
||||
"""
|
||||
Executor that drives the Hermes Agent CLI as a subprocess.
|
||||
|
||||
Hermes manages its own session persistence (SQLite). The executor
|
||||
captures the ``session_id`` from the first turn and passes
|
||||
``--resume <session_id>`` on subsequent turns so conversational
|
||||
history is maintained without Omnigent re-serializing the full
|
||||
message list.
|
||||
|
||||
Each turn runs ``hermes chat -q "<message>" -Q --source tool`` as an
|
||||
``asyncio.create_subprocess_exec`` subprocess, streams text output,
|
||||
and yields ``TextChunk`` / ``TurnComplete`` events.
|
||||
|
||||
A per-session ``HERMES_HOME`` directory is created with a
|
||||
``config.yaml`` that registers an Omnigent policy hook as a
|
||||
Hermes ``pre_tool_call`` shell hook, enforcing ``PHASE_TOOL_CALL``
|
||||
policies on all native Hermes tool calls.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hermes_path: str | None = None,
|
||||
cwd: str | None = None,
|
||||
model: str | None = None,
|
||||
os_env: OSEnvSpec | None = None,
|
||||
skills_filter: str | list[str] | None = None,
|
||||
bundle_dir: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param hermes_path: Path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
:param cwd: Working directory for the subprocess.
|
||||
``None`` uses ``os.getcwd()``.
|
||||
:param model: Model identifier override.
|
||||
``None`` uses Hermes' configured default.
|
||||
:param os_env: OS environment spec for the subprocess.
|
||||
``None`` defaults to ``caller_process + sandbox=none``.
|
||||
:param skills_filter: Skills filter forwarded to Hermes.
|
||||
``None`` means "no filter" (Hermes' default).
|
||||
:param bundle_dir: Agent bundle directory (reserved).
|
||||
:param agent_name: Agent display name (reserved).
|
||||
"""
|
||||
self._hermes_path = hermes_path or shutil.which("hermes") or "hermes"
|
||||
self._cwd = cwd or os.getcwd()
|
||||
self._model = model
|
||||
self._os_env = os_env or OSEnvSpec(
|
||||
type="caller_process",
|
||||
sandbox=OSEnvSandboxSpec(type="none"),
|
||||
)
|
||||
self._skills_filter = skills_filter
|
||||
self._bundle_dir = bundle_dir
|
||||
self._agent_name = agent_name
|
||||
# Per-session state: maps session_key -> hermes_session_id
|
||||
self._session_map: dict[str, str] = {}
|
||||
# Per-session HERMES_HOME with policy hook config.
|
||||
self._hermes_home: Path | None = None
|
||||
self._setup_hermes_home()
|
||||
|
||||
def _setup_hermes_home(self) -> None:
|
||||
"""Create a per-session ``HERMES_HOME`` with Omnigent policy hooks.
|
||||
|
||||
When the Omnigent server URL and conversation ID are available,
|
||||
creates a temp directory with a ``config.yaml`` that registers the
|
||||
Omnigent policy hook as a Hermes ``pre_tool_call`` shell hook.
|
||||
The ``HERMES_HOME`` env var is passed to the subprocess so Hermes
|
||||
reads this config instead of the user's ``~/.hermes/``.
|
||||
|
||||
Mirrors how Codex creates a per-session ``CODEX_HOME``.
|
||||
"""
|
||||
server_url = os.environ.get("RUNNER_SERVER_URL", "")
|
||||
conv_id = _get_conversation_id()
|
||||
if not server_url or not conv_id:
|
||||
_logger.warning(
|
||||
"Hermes policy hooks disabled: RUNNER_SERVER_URL=%r, conv_id=%r",
|
||||
server_url or "(unset)",
|
||||
conv_id or "(unset)",
|
||||
)
|
||||
return
|
||||
self._hermes_home = Path(tempfile.mkdtemp(prefix="hermes_home_"))
|
||||
hook_script = str(Path(__file__).with_name("hermes_policy_hook.py"))
|
||||
_populate_hermes_home(self._hermes_home, hook_script, server_url, conv_id)
|
||||
_logger.debug("Hermes per-session home: %s", self._hermes_home)
|
||||
|
||||
def _hermes_session_id(self, session_key: str) -> str | None:
|
||||
"""Return the stored Hermes session ID for an Omnigent session key."""
|
||||
return self._session_map.get(session_key)
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
"""Return True — Hermes streams text output."""
|
||||
return True
|
||||
|
||||
def handles_tools_internally(self) -> bool:
|
||||
"""Return True — Hermes executes tools inside its own agent loop.
|
||||
|
||||
The Hermes Agent CLI manages its own tool-calling loop internally.
|
||||
Tool-call requests/results are handled by Hermes, not bridged
|
||||
through Omnigent's tool dispatch. Omnigent policies are enforced
|
||||
via Hermes' native ``pre_tool_call`` shell hook that evaluates
|
||||
``PHASE_TOOL_CALL`` against the Omnigent server before each tool
|
||||
execution.
|
||||
"""
|
||||
return True
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
messages: list[Message],
|
||||
tools: list[ToolSpec],
|
||||
system_prompt: str,
|
||||
config: ExecutorConfig | None = None,
|
||||
) -> AsyncIterator[ExecutorEvent]:
|
||||
"""
|
||||
Run one agent turn by spawning ``hermes chat -q``.
|
||||
|
||||
:param messages: Conversation history from Omnigent.
|
||||
:param tools: Tool schemas (Hermes uses its own tools internally).
|
||||
:param system_prompt: System prompt (used by Hermes internally).
|
||||
:param config: Per-turn config (model override, etc.).
|
||||
:yields: ``TextChunk`` and ``TurnComplete`` events.
|
||||
:yields: ``ExecutorError`` on subprocess failure or timeout.
|
||||
"""
|
||||
_logger.debug(
|
||||
"HermesExecutor.run_turn: %d messages, tools=%d, prompt_len=%d",
|
||||
len(messages),
|
||||
len(tools),
|
||||
len(system_prompt),
|
||||
)
|
||||
|
||||
# Extract the latest user message
|
||||
user_text = _extract_last_user_message(messages)
|
||||
if not user_text:
|
||||
# Nothing to respond to — short-circuit
|
||||
yield TurnComplete(response=None)
|
||||
return
|
||||
|
||||
# Resolve model from config override, then instance default
|
||||
model = (config.model if config else None) or self._model
|
||||
|
||||
# Determine session key for this conversation
|
||||
session_key = self._session_key(messages)
|
||||
hermes_sid = self._hermes_session_id(session_key)
|
||||
|
||||
# Build the command-line arguments
|
||||
args = _build_hermes_args(
|
||||
hermes_path=self._hermes_path,
|
||||
message=user_text,
|
||||
model=model,
|
||||
session_id=hermes_sid,
|
||||
)
|
||||
|
||||
# Build subprocess env with per-session HERMES_HOME for policy hooks.
|
||||
proc_env: dict[str, str] | None = None
|
||||
if self._hermes_home is not None:
|
||||
proc_env = {**os.environ, "HERMES_HOME": str(self._hermes_home)}
|
||||
_logger.info("Hermes using per-session HERMES_HOME=%s", self._hermes_home)
|
||||
else:
|
||||
_logger.warning("Hermes running WITHOUT per-session HERMES_HOME (no policy hooks)")
|
||||
|
||||
_logger.debug("Hermes subprocess: %s", " ".join(args))
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self._cwd,
|
||||
env=proc_env,
|
||||
)
|
||||
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=_HERMES_TURN_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
_logger.warning("Hermes subprocess timed out after %ss", _HERMES_TURN_TIMEOUT_S)
|
||||
yield ExecutorError(
|
||||
message=f"Hermes subprocess timed out after {_HERMES_TURN_TIMEOUT_S}s",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
yield ExecutorError(
|
||||
message=(
|
||||
f"Hermes CLI not found at '{self._hermes_path}'. "
|
||||
"Install: curl -fsSL https://hermes-agent.nousresearch.com"
|
||||
"/install.sh | sh"
|
||||
),
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
except OSError as exc:
|
||||
yield ExecutorError(
|
||||
message=f"Failed to spawn Hermes subprocess: {exc}",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.strip() or stdout.strip()
|
||||
_logger.warning(
|
||||
"Hermes exited with code %d: %s",
|
||||
proc.returncode,
|
||||
error_msg[:500],
|
||||
)
|
||||
yield ExecutorError(
|
||||
message=f"Hermes exited with code {proc.returncode}: {error_msg[:500]}",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Store the session_id for subsequent turns
|
||||
parsed_sid = _parse_session_id(stdout)
|
||||
if parsed_sid and not hermes_sid:
|
||||
_logger.debug("Captured Hermes session_id: %s", parsed_sid)
|
||||
self._session_map[session_key] = parsed_sid
|
||||
|
||||
# Strip metadata lines to get the clean response
|
||||
response_text = _strip_hermes_metadata(stdout)
|
||||
|
||||
if response_text:
|
||||
yield TextChunk(text=response_text)
|
||||
|
||||
yield TurnComplete(response=response_text or None)
|
||||
|
||||
def _session_key(self, messages: list[Message]) -> str:
|
||||
"""
|
||||
Derive a stable Omnigent session key from the message list.
|
||||
|
||||
Uses the ``session_id`` stamped on the first message if available,
|
||||
otherwise falls back to a hash of the conversation content.
|
||||
"""
|
||||
for msg in messages:
|
||||
sid = msg.get("session_id")
|
||||
if isinstance(sid, str) and sid:
|
||||
return sid
|
||||
# Fallback: hash the serialised messages for a stable key
|
||||
return str(
|
||||
hash(tuple((m.get("role", ""), str(m.get("content", ""))[:200]) for m in messages))
|
||||
)
|
||||
|
||||
async def close_session(self, session_key: str) -> None:
|
||||
"""
|
||||
Release resources for a specific session.
|
||||
|
||||
Removes the Hermes session mapping — the Hermes session
|
||||
persists in its own SQLite store and can be resumed later
|
||||
via `hermes --resume` outside Omnigent.
|
||||
"""
|
||||
self._session_map.pop(session_key, None)
|
||||
await super().close_session(session_key)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Release executor-wide resources."""
|
||||
self._session_map.clear()
|
||||
# Best-effort cleanup of the per-session HERMES_HOME.
|
||||
if self._hermes_home is not None:
|
||||
shutil.rmtree(self._hermes_home, ignore_errors=True)
|
||||
self._hermes_home = None
|
||||
await super().close()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
``harness: hermes`` wrap.
|
||||
|
||||
Thin module exposing :func:`create_app` — the entrypoint the
|
||||
shared :mod:`omnigent.runtime.harnesses._runner` invokes after
|
||||
the parent process resolves ``"hermes"`` to this module via
|
||||
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`.
|
||||
|
||||
Internally, instantiates :class:`omnigent.runtime.harnesses._executor_adapter.ExecutorAdapter`
|
||||
around a :class:`omnigent.inner.hermes_executor.HermesExecutor`
|
||||
configured from env vars the parent process sets before spawning.
|
||||
|
||||
Mirrors the pi harness wrap (``pi_harness.py``); see that module's
|
||||
docstring for the v1 config-flow rationale (env vars vs per-request).
|
||||
|
||||
Env vars read at startup:
|
||||
|
||||
- ``HARNESS_HERMES_MODEL``: model identifier, e.g.
|
||||
``"deepseek/deepseek-chat"`` or ``"anthropic/claude-sonnet-4"``.
|
||||
``None`` falls back to Hermes' own configured default.
|
||||
- ``HARNESS_HERMES_CWD``: working directory the subprocess runs in.
|
||||
``None`` falls back to ``os.getcwd()``.
|
||||
- ``HARNESS_HERMES_PATH``: absolute path to the ``hermes`` CLI binary.
|
||||
``None`` searches ``PATH``.
|
||||
- ``HARNESS_HERMES_OS_ENV``: JSON-encoded :class:`OSEnvSpec`
|
||||
(from :func:`dataclasses.asdict`). When unset, the wrap
|
||||
falls back to a default
|
||||
``OSEnvSpec(type="caller_process", sandbox=type="none")`` so
|
||||
Omnigent mode parity with the legacy non-AP path holds for
|
||||
specs that don't declare an ``os_env:`` block.
|
||||
- ``HARNESS_HERMES_SKILLS_FILTER``: JSON-encoded
|
||||
``str | list[str]`` carrying ``spec.skills_filter``. When
|
||||
unset, falls back to ``"all"``.
|
||||
- ``HARNESS_HERMES_BUNDLE_DIR``: Absolute path to the agent
|
||||
bundle's extracted root. When set, the executor sources
|
||||
bundled skills from ``<bundle>/skills/<name>/``. Unset for
|
||||
agents without a bundled-skill directory.
|
||||
- ``HARNESS_HERMES_AGENT_NAME``: Agent display name. Reserved for
|
||||
future use; currently unused by Hermes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.executor import Executor
|
||||
from omnigent.inner.hermes_executor import HermesExecutor
|
||||
from omnigent.runtime.harnesses._executor_adapter import ExecutorAdapter
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Env-var keys the wrap reads at executor construction time. See
|
||||
# the module docstring for semantics. Centralizing as constants
|
||||
# so misconfigurations surface as a single grep target.
|
||||
_ENV_MODEL = "HARNESS_HERMES_MODEL"
|
||||
_ENV_CWD = "HARNESS_HERMES_CWD"
|
||||
_ENV_HERMES_PATH = "HARNESS_HERMES_PATH"
|
||||
_ENV_OS_ENV = "HARNESS_HERMES_OS_ENV"
|
||||
_ENV_SKILLS_FILTER = "HARNESS_HERMES_SKILLS_FILTER"
|
||||
_ENV_BUNDLE_DIR = "HARNESS_HERMES_BUNDLE_DIR"
|
||||
_ENV_AGENT_NAME = "HARNESS_HERMES_AGENT_NAME"
|
||||
|
||||
|
||||
def _resolve_os_env() -> OSEnvSpec:
|
||||
"""
|
||||
Resolve the inner-executor :class:`OSEnvSpec` from env config.
|
||||
|
||||
Reads :data:`_ENV_OS_ENV` and decodes the JSON-encoded dict
|
||||
Omnigent serialized via :func:`dataclasses.asdict` on its
|
||||
:class:`OSEnvSpec`. When the env var is missing or
|
||||
malformed, falls back to ``caller_process + sandbox=none``
|
||||
so AP-bridged tools stay enabled — matches the legacy
|
||||
non-AP path's default for specs without an
|
||||
``os_env:`` block.
|
||||
|
||||
:returns: An :class:`OSEnvSpec` to hand to
|
||||
:class:`HermesExecutor`.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_OS_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
_logger.warning(
|
||||
"%s is not valid JSON (%s); falling back to default os_env",
|
||||
_ENV_OS_ENV,
|
||||
exc,
|
||||
)
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
sandbox_payload = payload.get("sandbox")
|
||||
sandbox = (
|
||||
OSEnvSandboxSpec(**sandbox_payload) if isinstance(sandbox_payload, dict) else None
|
||||
)
|
||||
return OSEnvSpec(
|
||||
type=str(payload.get("type", "caller_process")),
|
||||
cwd=payload.get("cwd"),
|
||||
sandbox=sandbox,
|
||||
fork=bool(payload.get("fork", False)),
|
||||
)
|
||||
# Default: enable natives, no sandbox. Matches the simplest
|
||||
# working config; operators who want real sandbox enforcement
|
||||
# configure ``os_env.sandbox`` explicitly in the spec.
|
||||
return OSEnvSpec(
|
||||
type="caller_process",
|
||||
cwd=None,
|
||||
sandbox=OSEnvSandboxSpec(type="none"),
|
||||
fork=False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_skills_filter() -> str | list[str]:
|
||||
"""
|
||||
Resolve the inner-executor ``skills_filter`` from env config.
|
||||
|
||||
Reads :data:`_ENV_SKILLS_FILTER` and decodes the JSON-encoded
|
||||
``str | list[str]`` (``"all"``, ``"none"``, or a list of skill
|
||||
names). Falls back to ``"all"`` on missing or malformed input
|
||||
— matches the SDK default behavior.
|
||||
|
||||
:returns: ``"all"``, ``"none"``, or a list of skill names.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_SKILLS_FILTER, "").strip()
|
||||
if not raw:
|
||||
return "all"
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
_logger.warning(
|
||||
"%s is not valid JSON (%s); falling back to 'all'",
|
||||
_ENV_SKILLS_FILTER,
|
||||
exc,
|
||||
)
|
||||
return "all"
|
||||
if isinstance(decoded, str) and decoded in ("all", "none"):
|
||||
return decoded
|
||||
if isinstance(decoded, list) and all(isinstance(s, str) for s in decoded):
|
||||
return decoded
|
||||
_logger.warning(
|
||||
"%s decoded to unsupported shape %r; falling back to 'all'",
|
||||
_ENV_SKILLS_FILTER,
|
||||
decoded,
|
||||
)
|
||||
return "all"
|
||||
|
||||
|
||||
def _build_hermes_executor() -> Executor:
|
||||
"""
|
||||
Construct a :class:`HermesExecutor` from env-var config.
|
||||
|
||||
Called lazily by the :class:`ExecutorAdapter` on the first
|
||||
turn. Heavyweight init (CLI discovery) happens at this point
|
||||
— operators see the failure surface as a startup error on the
|
||||
first request, not at FastAPI app boot.
|
||||
|
||||
:returns: A configured :class:`HermesExecutor` instance.
|
||||
:raises FileNotFoundError: If ``hermes`` is not on PATH and
|
||||
``HARNESS_HERMES_PATH`` isn't set.
|
||||
"""
|
||||
bundle_dir_raw = os.environ.get(_ENV_BUNDLE_DIR, "").strip()
|
||||
bundle_dir = str(Path(bundle_dir_raw)) if bundle_dir_raw else None
|
||||
agent_name_raw = os.environ.get(_ENV_AGENT_NAME, "").strip()
|
||||
agent_name = agent_name_raw or None
|
||||
return HermesExecutor(
|
||||
hermes_path=os.environ.get(_ENV_HERMES_PATH),
|
||||
cwd=os.environ.get(_ENV_CWD) or os.environ.get("OMNIGENT_RUNNER_WORKSPACE"),
|
||||
os_env=_resolve_os_env(),
|
||||
model=os.environ.get(_ENV_MODEL),
|
||||
skills_filter=_resolve_skills_filter(),
|
||||
bundle_dir=bundle_dir,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
Build the hermes harness's FastAPI app.
|
||||
|
||||
Required entry point per the harness contract — the runner
|
||||
imports this module (resolved from
|
||||
:data:`omnigent.runtime.harnesses._HARNESS_MODULES`) and
|
||||
invokes ``create_app()`` to get the app it serves.
|
||||
|
||||
:returns: The FastAPI app from :class:`ExecutorAdapter`'s
|
||||
:meth:`build` method, with all routes from the harness
|
||||
API subset wired up. The wrapped :class:`HermesExecutor`
|
||||
is constructed lazily on the first turn (so an absent
|
||||
``hermes`` CLI surfaces as a request-time error, not a
|
||||
FastAPI app-boot crash).
|
||||
"""
|
||||
adapter = ExecutorAdapter(executor_factory=_build_hermes_executor)
|
||||
return adapter.build()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Hermes ``pre_tool_call`` shell hook for Omnigent policy enforcement.
|
||||
|
||||
Registered as a ``pre_tool_call`` hook in the per-session
|
||||
``HERMES_HOME/config.yaml`` written by :func:`_populate_hermes_home`
|
||||
in :mod:`hermes_executor`.
|
||||
|
||||
Hermes pipes a JSON payload to stdin before each tool execution::
|
||||
|
||||
{
|
||||
"hook_event_name": "pre_tool_call",
|
||||
"tool_name": "terminal",
|
||||
"tool_input": {"command": "rm -rf /"},
|
||||
"session_id": "...",
|
||||
"cwd": "..."
|
||||
}
|
||||
|
||||
The hook evaluates ``PHASE_TOOL_CALL`` policy via the Omnigent server.
|
||||
To block, it writes to stdout::
|
||||
|
||||
{"decision": "block", "reason": "..."}
|
||||
|
||||
Empty JSON or ``{}`` means allow.
|
||||
|
||||
Environment variables (set by the wrapper shell script):
|
||||
|
||||
_OMNIGENT_SERVER_URL : Base URL of the Omnigent server
|
||||
(e.g. ``http://127.0.0.1:6767``).
|
||||
_OMNIGENT_SESSION_ID : Session / conversation ID for policy
|
||||
evaluation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server_url = os.environ.get("_OMNIGENT_SERVER_URL", "")
|
||||
session_id = os.environ.get("_OMNIGENT_SESSION_ID", "")
|
||||
|
||||
if not server_url or not session_id:
|
||||
# No server wired -- fail open (allow).
|
||||
json.dump({}, sys.stdout)
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError, ValueError):
|
||||
json.dump({}, sys.stdout)
|
||||
return
|
||||
|
||||
tool_name = payload.get("tool_name") or "unknown"
|
||||
tool_input = payload.get("tool_input") or {}
|
||||
|
||||
# Build the evaluation request matching the server's EvaluationRequest
|
||||
# schema.
|
||||
eval_body: dict[str, object] = {
|
||||
"event": {
|
||||
"type": "PHASE_TOOL_CALL",
|
||||
"target": "",
|
||||
"data": {
|
||||
"name": tool_name,
|
||||
"arguments": tool_input if isinstance(tool_input, dict) else {},
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
}
|
||||
|
||||
url = f"{server_url.rstrip('/')}/v1/sessions/{session_id}/policies/evaluate"
|
||||
|
||||
try:
|
||||
from omnigent.native_policy_hook import post_evaluate_with_retry
|
||||
|
||||
resp = post_evaluate_with_retry(
|
||||
url=url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
eval_request=eval_body,
|
||||
# One day — must match the server's ``ask_timeout`` so the hook
|
||||
# stays alive while the human responds to the web-UI approval card.
|
||||
read_timeout=86400.0,
|
||||
hook_label="hermes pre_tool_call",
|
||||
)
|
||||
except Exception: # noqa: BLE001 -- fail open on import / unexpected error
|
||||
json.dump({}, sys.stdout)
|
||||
return
|
||||
|
||||
if resp is None:
|
||||
# Network error / retry budget exhausted -- fail closed so a
|
||||
# transient server outage doesn't let unreviewed tools through.
|
||||
json.dump(
|
||||
{"decision": "block", "reason": "Policy evaluation unavailable"},
|
||||
sys.stdout,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
result = resp.json()
|
||||
except Exception: # noqa: BLE001
|
||||
json.dump(
|
||||
{"decision": "block", "reason": "Malformed policy response"},
|
||||
sys.stdout,
|
||||
)
|
||||
return
|
||||
|
||||
action = result.get("result", "POLICY_ACTION_ALLOW")
|
||||
reason = result.get("reason", "")
|
||||
|
||||
if action == "POLICY_ACTION_DENY":
|
||||
out: dict[str, str] = {"decision": "block"}
|
||||
if reason:
|
||||
out["reason"] = f"Tool '{tool_name}' denied by Omnigent policy: {reason}"
|
||||
else:
|
||||
out["reason"] = f"Tool '{tool_name}' denied by Omnigent policy"
|
||||
json.dump(out, sys.stdout)
|
||||
elif action == "POLICY_ACTION_ASK":
|
||||
# The server resolves ASK by parking the HTTP request until the
|
||||
# human decides via the web-UI approval card and returning a hard
|
||||
# ALLOW/DENY. Receiving ASK here means the gate was not held
|
||||
# — fail closed rather than granting unreviewed permission.
|
||||
out = {"decision": "block"}
|
||||
if reason:
|
||||
out["reason"] = f"Tool '{tool_name}' requires approval: {reason}"
|
||||
else:
|
||||
out["reason"] = f"Tool '{tool_name}' requires approval"
|
||||
json.dump(out, sys.stdout)
|
||||
else:
|
||||
# ALLOW or UNSPECIFIED — empty JSON means no objection.
|
||||
json.dump({}, sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -74,6 +74,11 @@ GOOSE_KEY = "goose"
|
||||
# is kept here purely as the canonical harness id the readiness layer shares.
|
||||
COPILOT_KEY = "copilot"
|
||||
|
||||
# Hermes Agent is installed via a curl installer from Nous Research and
|
||||
# authenticates through its own ``hermes model`` interactive flow (no
|
||||
# Omnigent-managed credentials). The ``hermes`` binary must be on PATH.
|
||||
HERMES_KEY = "hermes"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarnessInstallSpec:
|
||||
@@ -170,6 +175,12 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
|
||||
package=None,
|
||||
install_hint="brew install block-goose-cli",
|
||||
),
|
||||
HERMES_KEY: HarnessInstallSpec(
|
||||
"Hermes",
|
||||
"hermes",
|
||||
package=None,
|
||||
install_hint="curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +220,8 @@ _HARNESS_NAME_TO_KEY: dict[str, str] = {
|
||||
# ``native-opencode`` reversed spelling gates on the same binary.
|
||||
"opencode-native": OPENCODE_KEY,
|
||||
"native-opencode": OPENCODE_KEY,
|
||||
# Hermes Agent (``harness: hermes``) wraps the ``hermes`` CLI.
|
||||
HERMES_KEY: HERMES_KEY,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from omnigent.onboarding.harness_install import (
|
||||
COPILOT_KEY,
|
||||
CURSOR_KEY,
|
||||
GOOSE_KEY,
|
||||
HERMES_KEY,
|
||||
OPENCODE_KEY,
|
||||
PI_KEY,
|
||||
QWEN_KEY,
|
||||
@@ -154,6 +155,11 @@ def harness_is_configured(harness: str) -> bool:
|
||||
# Auth/provider state surfaces at run time via Goose's own config; the
|
||||
# daemon gates only on binary presence.
|
||||
return harness_cli_installed(GOOSE_KEY)
|
||||
if canonical == HERMES_KEY:
|
||||
# Hermes wraps the ``hermes`` CLI (installed via a curl script from
|
||||
# Nous Research). Auth/provider config surfaces at run time via
|
||||
# Hermes' own ``hermes model`` flow; gate only on binary presence.
|
||||
return harness_cli_installed(HERMES_KEY)
|
||||
if canonical == CURSOR_KEY:
|
||||
# Cursor runs in-process via ``cursor-sdk`` and authenticates with a
|
||||
# ``CURSOR_API_KEY`` (a ``cursor-agent login`` does not apply). So,
|
||||
@@ -223,5 +229,6 @@ def configured_harness_map() -> dict[str, bool]:
|
||||
spellings.update(_QWEN_HARNESSES)
|
||||
spellings.add(CURSOR_KEY)
|
||||
spellings.add(GOOSE_KEY) # headless Goose (``goose acp``) gates on the goose binary
|
||||
spellings.add(HERMES_KEY) # Hermes Agent wraps the ``hermes`` CLI
|
||||
spellings.add(COPILOT_KEY)
|
||||
return {spelling: harness_is_configured(spelling) for spelling in spellings}
|
||||
|
||||
@@ -38,6 +38,13 @@ _CURSOR_NATIVE_OS_TOOLS = frozenset({"Shell"})
|
||||
# previews below resolve without a Pi-specific arg branch.
|
||||
_PI_NATIVE_OS_TOOLS = frozenset({"read", "bash", "write", "edit"})
|
||||
|
||||
# Hermes Agent tool names surfaced via the ``pre_tool_call`` shell hook
|
||||
# (see ``omnigent.inner.hermes_policy_hook``). Hermes uses its own naming
|
||||
# convention for file/shell operations.
|
||||
_HERMES_OS_TOOLS = frozenset(
|
||||
{"terminal", "execute_code", "read_file", "write_file", "search_files"}
|
||||
)
|
||||
|
||||
|
||||
# ── Rate limiting ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -85,7 +92,7 @@ def max_tool_calls_per_session(limit: int = 100) -> PolicyCallable:
|
||||
def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
"""ASK for user approval before any file or shell tool call.
|
||||
|
||||
Covers five tool-name families:
|
||||
Covers six tool-name families:
|
||||
|
||||
- **Omnigent built-in OS tools** (``sys_os_read``,
|
||||
``sys_os_write``, ``sys_os_edit``, ``sys_os_shell``).
|
||||
@@ -100,6 +107,9 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
- **Pi native tools** (``read``, ``bash``, ``write``, ``edit``)
|
||||
— surfaced via the pi ``tool_call`` extension hook. Lowercase
|
||||
and distinct from the Claude/Codex casing.
|
||||
- **Hermes Agent tools** (``terminal``, ``execute_code``,
|
||||
``read_file``, ``write_file``, ``search_files``) — surfaced
|
||||
via the ``pre_tool_call`` shell hook.
|
||||
|
||||
Returns ASK so the user sees an approval prompt before the tool
|
||||
executes.
|
||||
@@ -115,15 +125,21 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
return _ALLOW
|
||||
tool = data.get("name", "")
|
||||
_all_os_tools = (
|
||||
_SYS_OS_TOOLS | _NATIVE_OS_TOOLS | _CURSOR_NATIVE_OS_TOOLS | _PI_NATIVE_OS_TOOLS
|
||||
_SYS_OS_TOOLS
|
||||
| _NATIVE_OS_TOOLS
|
||||
| _CURSOR_NATIVE_OS_TOOLS
|
||||
| _PI_NATIVE_OS_TOOLS
|
||||
| _HERMES_OS_TOOLS
|
||||
)
|
||||
if tool in _all_os_tools:
|
||||
args = data.get("arguments", {})
|
||||
# Build a short preview of what the tool is doing.
|
||||
if tool in ("sys_os_shell", "Bash", "bash", "Shell"):
|
||||
if tool in ("sys_os_shell", "Bash", "bash", "Shell", "terminal"):
|
||||
preview = args.get("command", "") if isinstance(args, dict) else ""
|
||||
elif tool in ("Grep", "Glob"):
|
||||
elif tool in ("Grep", "Glob", "search_files"):
|
||||
preview = args.get("pattern", "") if isinstance(args, dict) else ""
|
||||
elif tool == "execute_code":
|
||||
preview = args.get("code", "")[:80] if isinstance(args, dict) else ""
|
||||
else:
|
||||
# Omnigent tools use ``path``; Claude native tools use ``file_path``.
|
||||
preview = (
|
||||
@@ -531,7 +547,8 @@ POLICY_REGISTRY: list[dict[str, Any]] = [
|
||||
"name": "Require Approval for File & Shell Operations",
|
||||
"description": "Asks for user approval before any file or shell tool call — "
|
||||
"covers Omnigent sys_os_* tools, Claude Code native tools "
|
||||
"(Bash, Read, Write, Edit, Glob, Grep), and Codex native tools",
|
||||
"(Bash, Read, Write, Edit, Glob, Grep), Codex native tools, "
|
||||
"and Hermes Agent tools (terminal, execute_code, read_file, write_file, search_files)",
|
||||
"params_schema": None,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -111,6 +111,12 @@ _HARNESS_MODULES: dict[str, str] = {
|
||||
# Omnigent spawns no separately-installed CLI. Authenticates against GitHub's
|
||||
# Copilot backend with a GitHub token (no Databricks gateway).
|
||||
"copilot": "omnigent.inner.copilot_harness",
|
||||
# Hermes Agent harness wrap. Runs the ``hermes`` CLI as a subprocess
|
||||
# for each turn, managing its own session state via Hermes' SQLite
|
||||
# session store. See omnigent/inner/hermes_harness.py and
|
||||
# omnigent/inner/hermes_executor.py. The ``hermes`` binary must be
|
||||
# on PATH (or set by HARNESS_HERMES_PATH).
|
||||
"hermes": "omnigent.inner.hermes_harness",
|
||||
}
|
||||
|
||||
__all__ = ["_HARNESS_MODULES"]
|
||||
|
||||
@@ -89,6 +89,7 @@ OMNIGENT_HARNESSES = frozenset(
|
||||
"cursor-native",
|
||||
"goose",
|
||||
"goose-native",
|
||||
"hermes",
|
||||
"openai-agents",
|
||||
"open-responses",
|
||||
"opencode-native",
|
||||
|
||||
@@ -186,6 +186,11 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
|
||||
(tmux pane + bridge dir, driving qwen's ``--input-file`` / ``--json-file``),
|
||||
not ``omnigent run --harness qwen-native``. Its coverage is the dedicated
|
||||
qwen-native bridge/executor/forwarder unit tests.
|
||||
|
||||
``hermes`` is excluded because it requires the ``hermes`` CLI binary
|
||||
(installed separately via Nous Research's install script) and authenticates
|
||||
through its own provider config, not the shared gateway/profile probe
|
||||
wiring this matrix drives.
|
||||
"""
|
||||
expected_live_harnesses = set(OMNIGENT_HARNESSES).intersection(_HARNESS_MODULES) - {
|
||||
"claude-native",
|
||||
@@ -200,5 +205,6 @@ def test_run_harness_live_matrix_covers_registered_coding_harnesses() -> None:
|
||||
"qwen-native",
|
||||
"goose",
|
||||
"goose-native",
|
||||
"hermes",
|
||||
}
|
||||
assert {probe.harness for probe in HARNESS_PROBES} == expected_live_harnesses
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
Unit tests for :class:`HermesExecutor` and its helper functions.
|
||||
|
||||
Tests the executor's parsing, session management, and argument
|
||||
building without invoking the real Hermes CLI. Subprocess-level
|
||||
integration tests belong in the e2e suite.
|
||||
|
||||
HermesExecutor's ``run_turn`` method is tested with a patched
|
||||
``asyncio.create_subprocess_exec`` to verify event emission
|
||||
patterns and error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pathlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
TextChunk,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.inner.hermes_executor import (
|
||||
HermesExecutor,
|
||||
_build_hermes_args,
|
||||
_extract_last_user_message,
|
||||
_parse_session_id,
|
||||
_populate_hermes_home,
|
||||
_strip_hermes_metadata,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUtils:
|
||||
"""Tests for standalone helper functions in hermes_executor."""
|
||||
|
||||
def test_strip_hermes_metadata_removes_session_id_line(self) -> None:
|
||||
output = "session_id: 20260620_123456_abc123\nHello, world!"
|
||||
assert _strip_hermes_metadata(output) == "Hello, world!"
|
||||
|
||||
def test_strip_hermes_metadata_removes_resume_notice(self) -> None:
|
||||
output = (
|
||||
"↻ Resumed session 20260620_123456_abc123 (1 message)\n"
|
||||
"\nsession_id: 20260620_123456_abc123\nHello again!"
|
||||
)
|
||||
assert _strip_hermes_metadata(output) == "Hello again!"
|
||||
|
||||
def test_strip_hermes_metadata_removes_warnings(self) -> None:
|
||||
output = "Warning: Unknown toolsets: messaging\nsession_id: abc\nHello!"
|
||||
assert _strip_hermes_metadata(output) == "Hello!"
|
||||
|
||||
def test_strip_hermes_metadata_preserves_empty_response(self) -> None:
|
||||
output = "session_id: 20260620_123456_abc123\n"
|
||||
assert _strip_hermes_metadata(output) == ""
|
||||
|
||||
def test_strip_hermes_metadata_preserves_multi_line_response(self) -> None:
|
||||
output = "session_id: 123\nLine one\nLine two\nLine three"
|
||||
assert _strip_hermes_metadata(output) == "Line one\nLine two\nLine three"
|
||||
|
||||
def test_parse_session_id_found(self) -> None:
|
||||
output = "Warning: something\nsession_id: 20260620_abc123_def456\nResponse text"
|
||||
assert _parse_session_id(output) == "20260620_abc123_def456"
|
||||
|
||||
def test_parse_session_id_not_found(self) -> None:
|
||||
output = "No session ID here"
|
||||
assert _parse_session_id(output) is None
|
||||
|
||||
def test_parse_session_id_empty_output(self) -> None:
|
||||
assert _parse_session_id("") is None
|
||||
|
||||
def test_extract_last_user_message_simple(self) -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": "First message"},
|
||||
{"role": "assistant", "content": "First response"},
|
||||
{"role": "user", "content": "Second message"},
|
||||
]
|
||||
assert _extract_last_user_message(messages) == "Second message"
|
||||
|
||||
def test_extract_last_user_message_content_blocks(self) -> None:
|
||||
messages = [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]},
|
||||
]
|
||||
assert _extract_last_user_message(messages) == "Hello"
|
||||
|
||||
def test_extract_last_user_message_empty(self) -> None:
|
||||
assert _extract_last_user_message([]) == ""
|
||||
|
||||
def test_extract_last_user_message_no_user(self) -> None:
|
||||
messages = [{"role": "assistant", "content": "Hello"}]
|
||||
assert _extract_last_user_message(messages) == ""
|
||||
|
||||
def test_build_hermes_args_basic(self) -> None:
|
||||
args = _build_hermes_args("/usr/bin/hermes", "Hello")
|
||||
assert args == [
|
||||
"/usr/bin/hermes",
|
||||
"chat",
|
||||
"-q",
|
||||
"Hello",
|
||||
"-Q",
|
||||
"--source",
|
||||
"tool",
|
||||
]
|
||||
|
||||
def test_build_hermes_args_with_model(self) -> None:
|
||||
args = _build_hermes_args("hermes", "Hi", model="deepseek/deepseek-chat")
|
||||
assert "-m" in args
|
||||
assert "deepseek/deepseek-chat" in args
|
||||
|
||||
def test_build_hermes_args_with_session(self) -> None:
|
||||
args = _build_hermes_args("hermes", "Hi", session_id="20260620_abc123")
|
||||
assert "--resume" in args
|
||||
idx = args.index("--resume")
|
||||
assert args[idx + 1] == "20260620_abc123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_HOME population tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPopulateHermesHome:
|
||||
"""Tests for the per-session HERMES_HOME setup."""
|
||||
|
||||
def test_creates_config_with_hook(self, tmp_path: pathlib.Path) -> None:
|
||||
"""config.yaml contains the pre_tool_call hook registration."""
|
||||
_populate_hermes_home(
|
||||
tmp_path,
|
||||
"/path/to/hook.py",
|
||||
"http://127.0.0.1:6767",
|
||||
"conv_test123",
|
||||
)
|
||||
config_path = tmp_path / "config.yaml"
|
||||
assert config_path.exists()
|
||||
config = json.loads(config_path.read_text())
|
||||
assert config["hooks_auto_accept"] is True
|
||||
hooks = config["hooks"]["pre_tool_call"]
|
||||
assert len(hooks) == 1
|
||||
assert "omnigent-policy-hook.sh" in hooks[0]["command"]
|
||||
|
||||
def test_creates_wrapper_script(self, tmp_path: pathlib.Path) -> None:
|
||||
"""Wrapper script exports env vars and execs the Python hook."""
|
||||
_populate_hermes_home(
|
||||
tmp_path,
|
||||
"/path/to/hook.py",
|
||||
"http://127.0.0.1:6767",
|
||||
"conv_test123",
|
||||
)
|
||||
wrapper = tmp_path / "omnigent-policy-hook.sh"
|
||||
assert wrapper.exists()
|
||||
content = wrapper.read_text()
|
||||
assert "http://127.0.0.1:6767" in content
|
||||
assert "conv_test123" in content
|
||||
assert "/path/to/hook.py" in content
|
||||
|
||||
def test_creates_allowlist(self, tmp_path: pathlib.Path) -> None:
|
||||
"""shell-hooks-allowlist.json is pre-populated with correct format."""
|
||||
_populate_hermes_home(
|
||||
tmp_path,
|
||||
"/path/to/hook.py",
|
||||
"http://127.0.0.1:6767",
|
||||
"conv_test123",
|
||||
)
|
||||
allowlist_path = tmp_path / "shell-hooks-allowlist.json"
|
||||
assert allowlist_path.exists()
|
||||
allowlist = json.loads(allowlist_path.read_text())
|
||||
approvals = allowlist["approvals"]
|
||||
assert len(approvals) == 1
|
||||
assert approvals[0]["event"] == "pre_tool_call"
|
||||
assert "omnigent-policy-hook.sh" in approvals[0]["command"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HermesExecutor unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor() -> HermesExecutor:
|
||||
"""Return a HermesExecutor with a dummy path for testing."""
|
||||
return HermesExecutor(
|
||||
hermes_path="/usr/bin/hermes-fake",
|
||||
cwd="/tmp",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_returns_text_chunk_and_turn_complete(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""A successful subprocess call yields TextChunk + TurnComplete."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate = AsyncMock(
|
||||
return_value=(
|
||||
b"session_id: 20260620_test_sid\nHello, world!",
|
||||
b"",
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
):
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) >= 2
|
||||
assert isinstance(events[-1], TurnComplete)
|
||||
assert events[-1].response == "Hello, world!"
|
||||
# At least one TextChunk should be present
|
||||
text_chunks = [e for e in events if isinstance(e, TextChunk)]
|
||||
assert len(text_chunks) >= 1
|
||||
assert text_chunks[0].text == "Hello, world!"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_empty_message_yields_none(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""No user message should short-circuit with TurnComplete(response=None)."""
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "assistant", "content": "Hello"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], TurnComplete)
|
||||
assert events[0].response is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_subprocess_timeout(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""A timed-out subprocess yields ExecutorError."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.communicate = AsyncMock(side_effect=asyncio.TimeoutError)
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
):
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ExecutorError)
|
||||
assert "timed out" in events[0].message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_subprocess_error(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""A non-zero exit code yields ExecutorError."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 1
|
||||
mock_process.communicate = AsyncMock(return_value=(b"", b"Error: something went wrong"))
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
):
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ExecutorError)
|
||||
assert "Something went wrong" in events[0].message or "error" in events[0].message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_file_not_found(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""A missing Hermes binary yields ExecutorError with install hint."""
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(side_effect=FileNotFoundError),
|
||||
):
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], ExecutorError)
|
||||
assert "Hermes CLI not found" in events[0].message
|
||||
assert "install" in events[0].message.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_stores_session_id(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""The executor captures session_id from the first turn for resume."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate = AsyncMock(
|
||||
return_value=(
|
||||
b"session_id: 20260620_captured_sid\nResponse text",
|
||||
b"",
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
):
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi", "session_id": "test-session-key"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Verify the session ID was stored
|
||||
assert executor._hermes_session_id("test-session-key") == "20260620_captured_sid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_resumes_existing_session(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""When a session_id is already stored, subsequent turns use --resume."""
|
||||
# Pre-populate the session map
|
||||
executor._session_map["test-session-key"] = "20260620_existing_sid"
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate = AsyncMock(
|
||||
return_value=(b"session_id: 20260620_existing_sid\nFollow-up response", b"")
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
) as mock_create:
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Follow up", "session_id": "test-session-key"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
# Verify --resume was used in the subprocess args
|
||||
call_args, _ = mock_create.call_args
|
||||
assert "--resume" in call_args
|
||||
resume_idx = list(call_args).index("--resume")
|
||||
assert list(call_args)[resume_idx + 1] == "20260620_existing_sid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_passes_model_from_config(
|
||||
executor: HermesExecutor,
|
||||
) -> None:
|
||||
"""Model from ExecutorConfig.extra or config.model is threaded through."""
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate = AsyncMock(return_value=(b"session_id: test\nResponse", b""))
|
||||
config = ExecutorConfig(model="deepseek/deepseek-chat")
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
) as mock_create:
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
config=config,
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
call_args, _ = mock_create.call_args
|
||||
assert "-m" in call_args
|
||||
idx = list(call_args).index("-m")
|
||||
assert list(call_args)[idx + 1] == "deepseek/deepseek-chat"
|
||||
|
||||
|
||||
def test_handles_tools_internally(executor: HermesExecutor) -> None:
|
||||
"""HermesExecutor reports it handles its own tool calls."""
|
||||
assert executor.handles_tools_internally() is True
|
||||
|
||||
|
||||
def test_no_hermes_home_without_server_env(executor: HermesExecutor) -> None:
|
||||
"""Without RUNNER_SERVER_URL, no per-session HERMES_HOME is created."""
|
||||
assert executor._hermes_home is None
|
||||
|
||||
|
||||
def test_hermes_home_setup_creates_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
"""When server URL and conv ID are available, HERMES_HOME is populated."""
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:6767")
|
||||
monkeypatch.setattr("sys.argv", ["harness", "--conversation-id", "conv_test123"])
|
||||
executor = HermesExecutor(hermes_path="/usr/bin/hermes-fake", cwd=str(tmp_path))
|
||||
assert executor._hermes_home is not None
|
||||
config_path = executor._hermes_home / "config.yaml"
|
||||
assert config_path.exists()
|
||||
config = json.loads(config_path.read_text())
|
||||
assert config["hooks_auto_accept"] is True
|
||||
assert "pre_tool_call" in config["hooks"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_turn_passes_hermes_home_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
"""When HERMES_HOME is set up, it's passed to the subprocess env."""
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:6767")
|
||||
monkeypatch.setattr("sys.argv", ["harness", "--conversation-id", "conv_test456"])
|
||||
executor = HermesExecutor(hermes_path="/usr/bin/hermes-fake", cwd=str(tmp_path))
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = 0
|
||||
mock_process.communicate = AsyncMock(return_value=(b"session_id: test\nOK", b""))
|
||||
|
||||
with patch.object(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
new=AsyncMock(return_value=mock_process),
|
||||
) as mock_create:
|
||||
events = []
|
||||
async for event in executor.run_turn(
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=[],
|
||||
system_prompt="",
|
||||
):
|
||||
events.append(event)
|
||||
|
||||
_, call_kwargs = mock_create.call_args
|
||||
assert "env" in call_kwargs
|
||||
assert call_kwargs["env"]["HERMES_HOME"] == str(executor._hermes_home)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Tests for the ``harness: hermes`` wrap shape.
|
||||
|
||||
Mirror of ``tests/inner/test_pi_harness.py`` — verifies the wrap
|
||||
module has the same shape (registry entry, FastAPI app routes,
|
||||
env-var-driven lazy executor construction). Does NOT exercise
|
||||
the real Hermes CLI; the inner ``HermesExecutor.__init__`` is
|
||||
lightweight enough that no mocking is needed for shape tests.
|
||||
|
||||
End-to-end Hermes verification (real CLI, real API) should live
|
||||
in the e2e suite, gated on the ``hermes`` binary being available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.inner import hermes_harness
|
||||
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
||||
|
||||
|
||||
def test_harness_module_registered_in_module_registry() -> None:
|
||||
"""``"hermes"`` resolves to the harness module path.
|
||||
|
||||
Without this entry, the runner subprocess can't find the wrap
|
||||
when AP-side tries to spawn it for a ``harness: hermes`` spec.
|
||||
"""
|
||||
assert _HARNESS_MODULES.get("hermes") == "omnigent.inner.hermes_harness"
|
||||
|
||||
|
||||
def test_create_app_returns_fastapi_with_required_routes() -> None:
|
||||
"""``create_app()`` returns a FastAPI app exposing the harness API.
|
||||
|
||||
Verifies the wrap successfully:
|
||||
- Imports the executor adapter + Hermes executor module.
|
||||
- Builds the FastAPI app via ExecutorAdapter.build().
|
||||
- Mounts the standard harness routes.
|
||||
|
||||
The actual HermesExecutor is constructed lazily on the first
|
||||
turn (not at app build time), so this test passes without
|
||||
a real ``hermes`` CLI on PATH.
|
||||
"""
|
||||
app = hermes_harness.create_app()
|
||||
paths = {route.path for route in app.routes} # type: ignore[attr-defined]
|
||||
# Session-keyed harness API: liveness probe + single
|
||||
# discriminated-event endpoint per §The Harness API Subset.
|
||||
assert "/health" in paths
|
||||
assert "/v1/sessions/{conversation_id}/events" in paths
|
||||
|
||||
|
||||
def test_executor_factory_reads_env_vars(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Factory passes env-var values through to HermesExecutor.
|
||||
|
||||
Locks in the v1 config-flow contract: env vars set in AP's
|
||||
process before spawning the subprocess (which inherits
|
||||
them) are how the wrap learns its config. Verifies model,
|
||||
cwd, hermes_path all thread through.
|
||||
"""
|
||||
monkeypatch.setenv("HARNESS_HERMES_MODEL", "test-model-id")
|
||||
monkeypatch.setenv("HARNESS_HERMES_CWD", "/tmp/test-cwd")
|
||||
monkeypatch.setenv("HARNESS_HERMES_PATH", "/custom/path/hermes")
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
assert executor._hermes_path == "/custom/path/hermes"
|
||||
assert executor._cwd == "/tmp/test-cwd"
|
||||
assert executor._model == "test-model-id"
|
||||
|
||||
|
||||
def test_executor_factory_defaults_when_env_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Factory uses sensible defaults when env vars are not set."""
|
||||
monkeypatch.delenv("HARNESS_HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("HARNESS_HERMES_CWD", raising=False)
|
||||
monkeypatch.delenv("HARNESS_HERMES_PATH", raising=False)
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
# Should fall back to PATH search for "hermes"
|
||||
assert executor._hermes_path is not None
|
||||
assert "hermes" in executor._hermes_path
|
||||
# Model should be None (no override)
|
||||
assert executor._model is None
|
||||
|
||||
|
||||
def test_executor_factory_reads_os_env_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Factory decodes JSON-encoded OSEnvSpec."""
|
||||
import json
|
||||
|
||||
os_env_spec = {
|
||||
"type": "caller_process",
|
||||
"cwd": "/workspace",
|
||||
"sandbox": {"type": "none"},
|
||||
"fork": False,
|
||||
}
|
||||
monkeypatch.setenv("HARNESS_HERMES_OS_ENV", json.dumps(os_env_spec))
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
assert executor._os_env is not None
|
||||
assert executor._os_env.type == "caller_process"
|
||||
assert executor._os_env.cwd == "/workspace"
|
||||
assert executor._os_env.sandbox is not None
|
||||
assert executor._os_env.sandbox.type == "none"
|
||||
|
||||
|
||||
def test_executor_factory_reads_skills_filter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Factory decodes JSON-encoded skills_filter."""
|
||||
import json
|
||||
|
||||
monkeypatch.setenv("HARNESS_HERMES_SKILLS_FILTER", json.dumps(["skill-a", "skill-b"]))
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
assert executor._skills_filter == ["skill-a", "skill-b"]
|
||||
|
||||
|
||||
def test_executor_factory_skills_filter_default_all() -> None:
|
||||
"""Factory falls back to 'all' when skills_filter is unset."""
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
assert executor._skills_filter == "all"
|
||||
|
||||
|
||||
def test_executor_factory_reads_bundle_and_agent_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Factory reads bundle dir and agent name from env vars."""
|
||||
monkeypatch.setenv("HARNESS_HERMES_BUNDLE_DIR", "/tmp/bundle")
|
||||
monkeypatch.setenv("HARNESS_HERMES_AGENT_NAME", "my-hermes-agent")
|
||||
|
||||
executor = hermes_harness._build_hermes_executor()
|
||||
|
||||
assert executor._bundle_dir == "/tmp/bundle"
|
||||
assert executor._agent_name == "my-hermes-agent"
|
||||
@@ -94,6 +94,7 @@ def test_sdk_and_unknown_harnesses_are_never_gated(
|
||||
"native-cursor",
|
||||
"goose-native",
|
||||
"native-goose",
|
||||
"hermes",
|
||||
],
|
||||
)
|
||||
def test_cli_harness_configured_only_when_binary_installed(
|
||||
@@ -166,6 +167,8 @@ def test_configured_harness_map_covers_all_spellings(
|
||||
# Copilot SDK harness + its user-facing alias.
|
||||
"copilot",
|
||||
"github-copilot",
|
||||
# Hermes Agent harness — gates on the hermes CLI.
|
||||
"hermes",
|
||||
}
|
||||
assert set(result) == expected_keys
|
||||
|
||||
@@ -210,6 +213,7 @@ def test_configured_harness_map_gates_only_cli_harnesses(
|
||||
"goose-native",
|
||||
"native-goose",
|
||||
"qwen",
|
||||
"hermes",
|
||||
):
|
||||
assert result[cli] is False, f"{cli} should be gated on its CLI binary"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user