Compare commits

...

12 Commits

Author SHA1 Message Date
Tomu Hirata 45d964ebb8 feat(policy): add Hermes tool names to file & shell approval policy
The built-in "Require Approval for File & Shell Operations" policy only
matched tool names from Claude/Codex/Cursor/Pi. Hermes uses different
names (terminal, execute_code, read_file, write_file, search_files)
which were not recognized, so policy enforcement silently allowed all
Hermes tool calls.

Co-authored-by: Isaac
2026-06-25 01:16:22 +09:00
Tomu Hirata ecd36e562b style: fix ruff formatting for hermes executor and harness install
Co-authored-by: Isaac
2026-06-25 01:06:53 +09:00
Tomu Hirata 078bf57f09 fix(hermes): increase hook timeout to 86400s for ASK policy support
The shell hook subprocess timeout must match the server's ask_timeout
(one day) so the hook stays alive while the human responds to a web-UI
approval card. With the previous 60s timeout, ASK policy evaluations
would time out and Hermes would silently skip the hook.

Co-authored-by: Isaac
2026-06-25 00:49:07 +09:00
Tomu Hirata 476a78602b fix(hermes): use correct allowlist format for shell hooks
Hermes' allowlist format is {"approvals": [{"event": ..., "command": ...}]},
not {command: true}. The wrong format caused hooks to be registered but
not allowlisted, so policy enforcement never fired.

Also added diagnostic logging for when HERMES_HOME setup is skipped.

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 117fbf3ff5 fix(hermes): strip ⚠ warning lines from Hermes output
Hermes emits warnings with ⚠ prefix (e.g. tirith scanner notices) in
addition to "Warning:" prefixed lines. Strip both so they don't leak
through to the user.

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 9e78e64ecf fix(hermes): copy auth.json into per-session HERMES_HOME
Hermes stores provider credentials (from `hermes auth` / `hermes model`)
in auth.json. The per-session HERMES_HOME needs this file to
authenticate with the configured inference provider.

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 025f6f1b39 fix(hermes): only merge inference-relevant keys from user config
The full user config includes sections like secrets.bitwarden that
reference env vars (BWS_ACCESS_TOKEN) not available in the Omnigent
harness context. Filter to only model/provider keys needed for
inference authentication.

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 100125d022 fix(hermes): merge user config into per-session HERMES_HOME + add to omni setup
The per-session HERMES_HOME (created for policy hooks) was missing the
user's model/provider config from ~/.hermes/config.yaml, causing
"No inference provider configured" errors. Now merges the user's config
and .env into the per-session directory.

Also adds Hermes to omni setup (install spec, readiness gate, interactive
menu with `hermes model` drill-in).

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 34dea55f82 fix(test): exclude hermes from e2e harness coverage matrix
Hermes requires its own CLI binary and authenticates through its own
provider config rather than the shared gateway/profile probe wiring,
so it cannot be exercised by the standard HARNESS_PROBES matrix.

Co-authored-by: Isaac
2026-06-25 00:42:36 +09:00
Tomu Hirata 3f43efeb3f fix: remove examples/hermes, add hermes to spec harness allowlist
Remove the example bundle (not needed for the harness itself) to
fix the e2e coverage sync test. Add "hermes" to OMNIGENT_HARNESSES
so user-authored harness: hermes specs pass validation.

Co-authored-by: Isaac
2026-06-25 00:42:14 +09:00
Tomu Hirata 2186f148cc refactor(hermes): use HERMES_HOME + native pre_tool_call hook for policy enforcement
Replace the made-up --pre-tool-hook CLI flag with Hermes' real
pre_tool_call shell hook mechanism. Now creates a per-session
HERMES_HOME (like Codex's CODEX_HOME) containing:
- config.yaml with hooks_auto_accept and the pre_tool_call hook
- omnigent-policy-hook.sh wrapper that sets env vars
- shell-hooks-allowlist.json to skip consent prompts

The hook uses Hermes' native protocol: JSON on stdin with
hook_event_name/tool_name/tool_input, and {"decision": "block",
"reason": "..."} on stdout to deny.

Co-authored-by: Isaac
2026-06-25 00:42:14 +09:00
Tomu Hirata d12f7a273d feat(harness): add Hermes Agent harness with policy enforcement
Add harness: hermes that wraps the Hermes Agent CLI as an Omnigent
executor. Address review comments: remove harness-specific docs from
AGENT_YAML_SPEC.md and enforce Omnigent policies on Hermes native
tools via a --pre-tool-hook script that evaluates PHASE_TOOL_CALL
against the Omnigent server before each tool execution.

Co-authored-by: Isaac
2026-06-25 00:42:14 +09:00
14 changed files with 1682 additions and 6 deletions
+1 -1
View File
@@ -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
+86
View File
@@ -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
+587
View File
@@ -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()
+198
View File
@@ -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()
+134
View File
@@ -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()
+13
View File
@@ -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,
}
+7
View File
@@ -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}
+22 -5
View File
@@ -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,
},
{
+6
View File
@@ -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"]
+1
View File
@@ -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
+475
View File
@@ -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)
+142
View File
@@ -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"