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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/demos/hermes-model-switch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions omnigent/harness_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,13 @@ def _builtin_native_provider(key: str) -> NativeHarnessProvider:
"copilot": "HARNESS_COPILOT_MODEL",
"cursor": "HARNESS_CURSOR_MODEL",
"goose": "HARNESS_GOOSE_MODEL",
# ``hermes`` is a CLI-subprocess harness (OWN_AUTH, ``~/.hermes``); its
# executor reads ``HARNESS_HERMES_MODEL`` and forwards it to the CLI as
# ``hermes chat -m <model>`` (see :mod:`omnigent.inner.hermes_executor`).
# ``hermes-native`` (the resident TUI) is intentionally NOT here: like
# the other native CLIs it takes the model as a ``-m`` argv at terminal
# launch (see ``_auto_create_hermes_terminal``), not via a spawn-env var.
"hermes": "HARNESS_HERMES_MODEL",
"kimi": "HARNESS_KIMI_MODEL",
"openai-agents": "HARNESS_OPENAI_AGENTS_MODEL",
"pi": "HARNESS_PI_MODEL",
Expand Down
10 changes: 10 additions & 0 deletions omnigent/hermes_native_forwarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ def _read_model_from_hermes_config(bridge_dir: Path) -> str | None:
model = data.get("model")
if isinstance(model, str) and model:
return model
# Hermes' ``config.yaml`` stores the model as a mapping
# (``model: {default: <id>, provider: <name>, ...}`` — the shape
# ``hermes model`` writes and that the per-session HERMES_HOME
# inherits), not a bare string. Surface the default id so the web
# UI's usage/model readout reflects the configured model instead of
# silently showing nothing.
if isinstance(model, dict):
default = model.get("default")
if isinstance(default, str) and default:
return default
except Exception: # noqa: BLE001
continue
return None
Expand Down
1 change: 1 addition & 0 deletions omnigent/model_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"qwen",
"goose",
"copilot",
"hermes",
}
)
_SDK_MODEL_OVERRIDE_HARNESSES = frozenset(model_env_keys())
Expand Down
3 changes: 3 additions & 0 deletions omnigent/runner/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9569,6 +9569,7 @@ def _build_spawn_env_from_spec(
_build_copilot_spawn_env,
_build_cursor_spawn_env,
_build_goose_spawn_env,
_build_hermes_spawn_env,
_build_kimi_spawn_env,
_build_openai_agents_sdk_spawn_env,
_build_pi_spawn_env,
Expand All @@ -9587,6 +9588,8 @@ def _build_spawn_env_from_spec(
env = _build_cursor_spawn_env(effective_spec, cwd=cwd, workdir=workdir)
elif harness == "antigravity":
env = _build_antigravity_spawn_env(effective_spec)
elif harness == "hermes":
env = _build_hermes_spawn_env(effective_spec, cwd=cwd)
elif harness == "kimi":
env = _build_kimi_spawn_env(effective_spec, cwd=cwd)
elif harness == "qwen":
Expand Down
33 changes: 32 additions & 1 deletion omnigent/runner/native/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,29 @@ async def _supervise_goose_native_bridges() -> None:
return terminal_view


def _hermes_launch_args_with_model(base_args: list[str], model_override: str | None) -> list[str]:
"""Return *base_args* with a top-level ``-m <model>`` appended when a
per-session model override is set for the resident Hermes TUI.

hermes-native is a native harness, so — like cursor-native — the persisted
``/model`` (or ``args.model`` / ``executor.model``) override reaches the CLI
as a terminal-launch argv, not a ``HARNESS_<H>_MODEL`` spawn-env var. Skips
insertion when the user already pinned a model via passthrough launch args
(``omnigent hermes -- -m X``) so the CLI never sees two ``-m`` values.
Mirrors the ``--model`` guard in :func:`_auto_create_cursor_terminal`.

:param base_args: The user pass-through launch args (may be empty).
:param model_override: The validated per-session model override, or ``None``.
:returns: The launch args, with ``-m <model_override>`` appended when applicable.
"""
args = list(base_args)
if model_override and not any(
arg in ("-m", "--model") or arg.startswith("--model=") for arg in args
):
args.extend(["-m", model_override])
return args


async def _auto_create_hermes_terminal(
session_id: str,
resource_registry: SessionResourceRegistry,
Expand Down Expand Up @@ -2475,7 +2498,15 @@ async def _auto_create_hermes_terminal(
# ``started_at`` is at/after this instant (minus a small skew). A wiped bridge
# cursor (clear_hermes_bridge_state above) starts it at that row's first row.
launch_epoch_s = time.time()
hermes_args = [*(launch_config.terminal_launch_args or [])]
# Bake the persisted per-session ``/model`` override in as a top-level
# ``hermes -m <model>`` flag so the resident TUI launches on the chosen model
# — the native-CLI analog of the ``HARNESS_<H>_MODEL`` spawn-env var the SDK
# harnesses use (hermes-native cannot re-select a model per turn once the TUI
# is up, so the model is fixed here at launch).
hermes_args = _hermes_launch_args_with_model(
[*(launch_config.terminal_launch_args or [])],
launch_config.model_override,
)
# Resolve the per-session HERMES_HOME early: the fork block below needs it
# to place the cloned state.db, and the env block after needs it for the
# HERMES_HOME env var.
Expand Down
40 changes: 40 additions & 0 deletions omnigent/runtime/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,46 @@ def _build_kimi_spawn_env(
return env


def _build_hermes_spawn_env(
spec: AgentSpec,
*,
cwd: Path | None = None,
) -> dict[str, str]:
"""Build the env-var dict the ``hermes`` harness wrap reads.

Maps ``spec.executor`` fields → the ``HARNESS_HERMES_*`` env vars defined in
:mod:`omnigent.inner.hermes_harness`. The executor turns ``HARNESS_HERMES_MODEL``
into a ``hermes chat -m <model>`` flag, so threading the model here is what
makes ``args.model`` / ``executor.model`` (and a per-session ``/model``
override, applied on top in :func:`omnigent.runner.app._build_spawn_env_from_spec`)
actually reach the Hermes CLI.

Hermes is OWN_AUTH: provider/credentials live in ``~/.hermes`` (configured
once via ``hermes model`` / ``hermes auth``) and are copied into the
per-session ``HERMES_HOME`` by the executor, so — like the sibling
:func:`_build_kimi_spawn_env` — this builder threads only the model, working
directory, and ``os_env`` sandbox spec; there is no per-spawn provider
env-var surface to translate a ``ProviderAuth`` into.

:param spec: The agent spec.
:param cwd: Runtime working directory for the hermes subprocess — the
session workspace, not the agent bundle dir. Threaded as
``HARNESS_HERMES_CWD`` so Hermes' tools operate on the user's project;
when unset the wrap falls back to ``OMNIGENT_RUNNER_WORKSPACE``.
:returns: A dict of env-var overrides.
"""
env: dict[str, str] = {}
model = _resolve_spec_model(spec)
if model is not None:
env["HARNESS_HERMES_MODEL"] = model
if cwd is not None:
env["HARNESS_HERMES_CWD"] = str(cwd)
os_env_payload = _serialize_os_env(spec.os_env)
if os_env_payload is not None:
env["HARNESS_HERMES_OS_ENV"] = os_env_payload
return env


def _build_antigravity_spawn_env(spec: AgentSpec) -> dict[str, str]:
"""
Map ``spec.executor`` fields → the ``HARNESS_ANTIGRAVITY_*`` env vars the
Expand Down
120 changes: 120 additions & 0 deletions tests/runtime/test_hermes_spawn_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Tests for ``_build_hermes_spawn_env`` in ``omnigent/runtime/workflow.py`` and the
end-to-end model-override wiring for the ``hermes`` harness.

The spawn-env builder maps ``spec.executor`` fields to the ``HARNESS_HERMES_*``
env vars the hermes harness wrap reads at executor-construction time; the executor
then forwards ``HARNESS_HERMES_MODEL`` to the CLI as ``hermes chat -m <model>``
(covered in ``tests/inner/test_hermes_executor.py``). These are unit tests — no
subprocess spawn, no real hermes CLI.

Mirrors ``test_pi_spawn_env.py`` / the kimi sibling: hermes is a CLI-subprocess,
OWN_AUTH harness, so — unlike the gateway harnesses — the builder threads only the
model, working directory, and ``os_env`` sandbox spec (provider/credentials live in
``~/.hermes``).
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from omnigent.harness_plugins import model_env_keys
from omnigent.runtime.workflow import _build_hermes_spawn_env
from omnigent.spec.types import AgentSpec, ExecutorSpec, LLMConfig


@pytest.fixture(autouse=True)
def _isolate_global_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point OMNIGENT_CONFIG_HOME at an empty temp dir so the developer's real
global config can't hijack the model resolution under test."""
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))


def _make_spec(*, model: str | None = None) -> AgentSpec:
"""Build a minimal ``hermes`` :class:`AgentSpec` for spawn-env tests."""
config: dict[str, object] = {"harness": "hermes"}
if model is not None:
config["model"] = model
return AgentSpec(
spec_version=1,
name="test-hermes",
instructions="You are a test agent.",
executor=ExecutorSpec(type="omnigent", config=config, model=model),
llm=LLMConfig(model=model) if model is not None else None,
)


def test_hermes_registered_in_model_env_keys() -> None:
"""``hermes`` maps to ``HARNESS_HERMES_MODEL`` so ``args.model`` /
``executor.model`` (and a per-session ``/model`` override) can reach it.

``hermes-native`` is intentionally NOT here — it takes the model as a ``-m``
argv at TUI launch, like the other native CLIs.
"""
keys = model_env_keys()
assert keys.get("hermes") == "HARNESS_HERMES_MODEL"
assert "hermes-native" not in keys


def test_hermes_spawn_env_threads_model() -> None:
"""A spec model lands in ``HARNESS_HERMES_MODEL`` — the var the executor
forwards to the CLI as ``hermes chat -m <model>``."""
env = _build_hermes_spawn_env(_make_spec(model="anthropic/claude-sonnet-4"))
assert env["HARNESS_HERMES_MODEL"] == "anthropic/claude-sonnet-4"


def test_hermes_spawn_env_omits_model_when_unset() -> None:
"""No spec model → no ``HARNESS_HERMES_MODEL`` key, so Hermes falls back to
its own configured default rather than an empty override."""
env = _build_hermes_spawn_env(_make_spec(model=None))
assert "HARNESS_HERMES_MODEL" not in env


def test_hermes_spawn_env_threads_cwd() -> None:
"""The session workspace is threaded as ``HARNESS_HERMES_CWD`` so Hermes'
tools operate on the user's project, not the runner cwd."""
workspace = Path("/tmp/repo-under-test")
env = _build_hermes_spawn_env(_make_spec(), cwd=workspace)
assert env["HARNESS_HERMES_CWD"] == str(workspace)


def test_hermes_spawn_env_serializes_os_env() -> None:
"""``spec.os_env`` is serialized into ``HARNESS_HERMES_OS_ENV`` as JSON the
executor decodes back into an ``OSEnvSpec``."""
from omnigent.spec.types import OSEnvSpec

spec = _make_spec(model="gpt-5.5")
spec = AgentSpec(
spec_version=spec.spec_version,
name=spec.name,
instructions=spec.instructions,
executor=spec.executor,
llm=spec.llm,
os_env=OSEnvSpec(type="caller_process"),
)
env = _build_hermes_spawn_env(spec)
payload = json.loads(env["HARNESS_HERMES_OS_ENV"])
assert payload["type"] == "caller_process"


def test_build_spawn_env_from_spec_applies_model_override_for_hermes() -> None:
"""A per-session ``/model`` override wins over the spec model on the hermes
path — proving ``_build_spawn_env_from_spec`` returns a real env for hermes
(not ``None``) and applies the override into ``HARNESS_HERMES_MODEL``.

This is the regression guard for the whole point of the change: before
registering hermes, ``_build_spawn_env_from_spec`` returned ``None`` for it
and the override silently dropped.
"""
from omnigent.runner.app import _build_spawn_env_from_spec

env = _build_spawn_env_from_spec(
_make_spec(model="gpt-5.5"),
"hermes",
model_override="anthropic/claude-sonnet-4",
)
assert env is not None
assert env["HARNESS_HERMES_MODEL"] == "anthropic/claude-sonnet-4"
28 changes: 28 additions & 0 deletions tests/test_hermes_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,34 @@ def test_terminal_resource_id_stable() -> None:
assert hn.hermes_terminal_resource_id() == hn.hermes_terminal_resource_id()


def test_launch_args_with_model_appends_model_flag() -> None:
"""A per-session model override lands as a top-level ``-m <model>`` so the
resident Hermes TUI launches on the chosen model."""
from omnigent.runner.native.orchestration import _hermes_launch_args_with_model

args = _hermes_launch_args_with_model([], "anthropic/claude-sonnet-4")
assert args == ["-m", "anthropic/claude-sonnet-4"]


def test_launch_args_with_model_no_override_is_passthrough() -> None:
"""No override → args are untouched (Hermes uses its own configured default)."""
from omnigent.runner.native.orchestration import _hermes_launch_args_with_model

assert _hermes_launch_args_with_model(["--resume", "abc"], None) == ["--resume", "abc"]


@pytest.mark.parametrize(
"user_flag", [["-m", "gpt-4o"], ["--model", "gpt-4o"], ["--model=gpt-4o"]]
)
def test_launch_args_with_model_respects_user_pinned_model(user_flag: list[str]) -> None:
"""A model the user pinned via passthrough launch args wins — the override is
NOT appended, so the CLI never sees two ``-m`` values."""
from omnigent.runner.native.orchestration import _hermes_launch_args_with_model

out = _hermes_launch_args_with_model(list(user_flag), "anthropic/claude-sonnet-4")
assert out == user_flag


def test_harness_registry_has_hermes_native() -> None:
from omnigent.runtime.harnesses import _HARNESS_MODULES

Expand Down
31 changes: 28 additions & 3 deletions tests/test_hermes_native_forwarder.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,19 @@ async def test_usage_tracker_deduplicates(tmp_path, monkeypatch) -> None:
assert len(client.posts) == 1 # only the first flush posts


async def test_usage_tracker_no_post_when_no_model(tmp_path) -> None:
"""No config / no model -> nothing posted."""
async def test_usage_tracker_no_post_when_no_model(tmp_path, monkeypatch) -> None:
"""No per-session config and no readable user config -> nothing posted.

Isolate ``Path.home`` to an empty dir so the ``~/.hermes/config.yaml``
fallback in :func:`_read_model_from_hermes_config` genuinely finds nothing
(otherwise the developer's real Hermes config would leak a model in).
"""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(f.Path, "home", staticmethod(lambda: home))

client = _FakeClient()
tracker = f._HermesUsageTracker(client, "conv_none", tmp_path)
tracker = f._HermesUsageTracker(client, "conv_none", tmp_path / "bridge")
await tracker.flush()
assert len(client.posts) == 0

Expand All @@ -784,6 +793,22 @@ async def test_read_model_from_hermes_config_fallback(tmp_path, monkeypatch) ->
assert model == "from-user-config"


def test_read_model_from_hermes_config_mapping_shape(tmp_path) -> None:
"""A real Hermes ``config.yaml`` stores ``model`` as a mapping
(``{default, provider, ...}``), not a bare string — the reader must surface
``model.default`` so the web UI's model readout is not silently blank."""
bridge_dir = tmp_path / "bridge"
hermes_home = bridge_dir / "hermes_home"
hermes_home.mkdir(parents=True)
import yaml

(hermes_home / "config.yaml").write_text(
yaml.dump({"model": {"default": "gpt-5.5", "provider": "openai-codex"}})
)

assert f._read_model_from_hermes_config(bridge_dir) == "gpt-5.5"


# --- Compaction persistence tests -------------------------------------------

_COMPACTION_SCHEMA = """
Expand Down
5 changes: 5 additions & 0 deletions tests/test_model_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ def test_validate_model_override_rejects_unsafe_values(value: str) -> None:
"antigravity",
"kiro-native",
"native-kiro",
# hermes: model reaches the CLI via HARNESS_HERMES_MODEL (spawn-env);
# hermes-native takes ``-m`` at TUI launch (native harness).
"hermes",
"hermes-native",
"native-hermes",
],
)
def test_harness_supports_model_override_for_plumbed_harnesses(harness: str) -> None:
Expand Down
Loading