diff --git a/docs/demos/hermes-model-switch.png b/docs/demos/hermes-model-switch.png new file mode 100644 index 0000000000..7a17cfd977 Binary files /dev/null and b/docs/demos/hermes-model-switch.png differ diff --git a/omnigent/harness_plugins.py b/omnigent/harness_plugins.py index 19677a2ab5..020db03127 100644 --- a/omnigent/harness_plugins.py +++ b/omnigent/harness_plugins.py @@ -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 `` (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", diff --git a/omnigent/hermes_native_forwarder.py b/omnigent/hermes_native_forwarder.py index 31403d6aef..e4b4f7a811 100644 --- a/omnigent/hermes_native_forwarder.py +++ b/omnigent/hermes_native_forwarder.py @@ -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: , provider: , ...}`` — 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 diff --git a/omnigent/model_override.py b/omnigent/model_override.py index 94a54ac0a4..2a6f8e94ee 100644 --- a/omnigent/model_override.py +++ b/omnigent/model_override.py @@ -39,6 +39,7 @@ "qwen", "goose", "copilot", + "hermes", } ) _SDK_MODEL_OVERRIDE_HARNESSES = frozenset(model_env_keys()) diff --git a/omnigent/runner/app.py b/omnigent/runner/app.py index 0285ceee4c..3eeeaf4437 100644 --- a/omnigent/runner/app.py +++ b/omnigent/runner/app.py @@ -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, @@ -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": diff --git a/omnigent/runner/native/orchestration.py b/omnigent/runner/native/orchestration.py index bc30273496..3447468ca5 100644 --- a/omnigent/runner/native/orchestration.py +++ b/omnigent/runner/native/orchestration.py @@ -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 `` 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__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 `` 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, @@ -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 `` flag so the resident TUI launches on the chosen model + # — the native-CLI analog of the ``HARNESS__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. diff --git a/omnigent/runtime/workflow.py b/omnigent/runtime/workflow.py index 130b18a87f..8a342656ed 100644 --- a/omnigent/runtime/workflow.py +++ b/omnigent/runtime/workflow.py @@ -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 `` 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 diff --git a/tests/runtime/test_hermes_spawn_env.py b/tests/runtime/test_hermes_spawn_env.py new file mode 100644 index 0000000000..b0d03a5180 --- /dev/null +++ b/tests/runtime/test_hermes_spawn_env.py @@ -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 `` +(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 ``.""" + 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" diff --git a/tests/test_hermes_native.py b/tests/test_hermes_native.py index f0a2b6baa4..04e90e9023 100644 --- a/tests/test_hermes_native.py +++ b/tests/test_hermes_native.py @@ -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 `` 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 diff --git a/tests/test_hermes_native_forwarder.py b/tests/test_hermes_native_forwarder.py index 10e99d8a67..44a47865cc 100644 --- a/tests/test_hermes_native_forwarder.py +++ b/tests/test_hermes_native_forwarder.py @@ -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 @@ -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 = """ diff --git a/tests/test_model_override.py b/tests/test_model_override.py index 2ef6441aab..b1348cfa93 100644 --- a/tests/test_model_override.py +++ b/tests/test_model_override.py @@ -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: