diff --git a/agent/legacy_cli.py b/agent/legacy_cli.py new file mode 100644 index 0000000000..a76b73f275 --- /dev/null +++ b/agent/legacy_cli.py @@ -0,0 +1,81 @@ +"""Argument layer for the legacy ``hermes-agent`` runner (``run_agent.main``). + +A console script calls its target with no arguments, so pointing ``hermes-agent`` +at ``run_agent.main`` ignored argv entirely: ``--help``, ``--version`` and a bare +invocation all ran a real model turn with ``main()``'s built-in demo query, and +``--query`` was silently dropped (#54648). ``python run_agent.py`` routes through +here too, so the installer's PATH launcher behaves the same way. +""" + +from __future__ import annotations + +import argparse +from typing import Callable, List, Optional + + +def _build_parser() -> argparse.ArgumentParser: + from hermes_cli import __release_date__, __version__ + + parser = argparse.ArgumentParser( + prog="hermes-agent", + description="Legacy single-query Hermes Agent runner. For the full CLI use `hermes`.", + ) + parser.add_argument("--version", action="version", version=f"Hermes Agent v{__version__} ({__release_date__})") + parser.add_argument("prompt", nargs="*", help="query to run (same as --query)") + parser.add_argument("--query", "-q", help="natural-language query to run") + parser.add_argument("--model", default="", help="model id (provider/model)") + parser.add_argument("--api-key", "--api_key", dest="api_key", help="API key for the model endpoint") + parser.add_argument("--base-url", "--base_url", dest="base_url", default="", help="model API base URL") + parser.add_argument("--max-turns", "--max_turns", dest="max_turns", type=int, default=10, + help="maximum API call iterations (default: 10)") + parser.add_argument("--enabled-toolsets", "--enabled_toolsets", dest="enabled_toolsets", + help="comma-separated toolsets to enable") + parser.add_argument("--disabled-toolsets", "--disabled_toolsets", dest="disabled_toolsets", + help="comma-separated toolsets to disable") + parser.add_argument("--list-tools", "--list_tools", dest="list_tools", action="store_true", + help="list available tools and exit") + parser.add_argument("--save-trajectories", "--save_trajectories", dest="save_trajectories", + action="store_true", help="append the conversation to trajectory JSONL files") + parser.add_argument("--save-sample", "--save_sample", dest="save_sample", action="store_true", + help="save one trajectory sample to a UUID-named file") + parser.add_argument("--verbose", action="store_true", help="verbose logging") + parser.add_argument("--log-prefix-chars", "--log_prefix_chars", dest="log_prefix_chars", type=int, + default=20, help="characters shown in tool-call log previews (default: 20)") + return parser + + +def main(argv: Optional[List[str]] = None, *, run: Optional[Callable[..., object]] = None) -> int: + """Parse ``argv`` (default ``sys.argv[1:]``) and run one query through ``run_agent.main``. + + Metadata flags and a bare invocation never reach the runner; ``run`` lets + ``python run_agent.py`` pass its own ``main`` instead of importing the module twice. + """ + parser = _build_parser() + args = parser.parse_args(argv) + positional = " ".join(args.prompt).strip() + if args.query and positional: + parser.error("pass the query either positionally or via --query, not both") + query = args.query or positional or None + if query is None and not args.list_tools: + parser.print_help() + print("\nNo query given: pass one with --query (or run `hermes` for the interactive CLI).") + return 0 + + if run is None: + from run_agent import main as run + + run( + query=query, + model=args.model, + api_key=args.api_key, + base_url=args.base_url, + max_turns=args.max_turns, + enabled_toolsets=args.enabled_toolsets, + disabled_toolsets=args.disabled_toolsets, + list_tools=args.list_tools, + save_trajectories=args.save_trajectories, + save_sample=args.save_sample, + verbose=args.verbose, + log_prefix_chars=args.log_prefix_chars, + ) + return 0 diff --git a/pyproject.toml b/pyproject.toml index daec744860..fad1079011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -430,7 +430,7 @@ build-backend = "setuptools.build_meta" [project.scripts] hermes = "hermes_cli.main:main" -hermes-agent = "run_agent:legacy_cli_main" +hermes-agent = "agent.legacy_cli:main" hermes-acp = "acp_adapter.entry:main" [tool.uv] diff --git a/run_agent.py b/run_agent.py index 8d191fb4e4..032a5e7669 100644 --- a/run_agent.py +++ b/run_agent.py @@ -12,7 +12,6 @@ try: except ModuleNotFoundError: pass # partial `hermes update` — only skips the Windows UTF-8 stdio setup -import argparse import json import logging logger = logging.getLogger(__name__) @@ -1488,95 +1487,6 @@ def _save_sample_trajectory(agent: "AIAgent", result: dict, user_query: str, mod print(f"\n⚠️ Failed to save sample: {e}") -def _build_legacy_cli_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="hermes-agent", - description=( - "Legacy Hermes Agent runner. For the primary CLI, use the " - "`hermes` command." - ), - add_help=False, - ) - parser.add_argument( - "prompt", - nargs="*", - help="Natural language query to run. Prefer --query for scripted use.", - ) - parser.add_argument("-h", "--help", action="store_true", dest="show_help") - parser.add_argument("--version", "-V", action="store_true") - parser.add_argument("--query", "-q") - parser.add_argument("--model", default="") - parser.add_argument("--api-key", "--api_key", dest="api_key") - parser.add_argument("--base-url", "--base_url", dest="base_url", default="") - parser.add_argument("--max-turns", "--max_turns", dest="max_turns", type=int, default=10) - parser.add_argument("--enabled-toolsets", "--enabled_toolsets", dest="enabled_toolsets") - parser.add_argument("--disabled-toolsets", "--disabled_toolsets", dest="disabled_toolsets") - parser.add_argument("--list-tools", "--list_tools", dest="list_tools", action="store_true") - parser.add_argument( - "--save-trajectories", - "--save_trajectories", - dest="save_trajectories", - action="store_true", - ) - parser.add_argument("--save-sample", "--save_sample", dest="save_sample", action="store_true") - parser.add_argument("--verbose", action="store_true") - parser.add_argument( - "--log-prefix-chars", - "--log_prefix_chars", - dest="log_prefix_chars", - type=int, - default=20, - ) - return parser - - -def legacy_cli_main(argv: Optional[List[str]] = None) -> int: - """Console-script wrapper for the legacy ``hermes-agent`` entry point. - - The packaged console script calls the target function with no arguments. - Pointing it at ``main`` therefore used to start a live model request before - flags like ``--version`` or ``--help`` could be parsed. - """ - parser = _build_legacy_cli_parser() - args = parser.parse_args(argv) - - if args.version: - from hermes_cli import __release_date__, __version__ - - print(f"Hermes Agent v{__version__} ({__release_date__})") - return 0 - - if args.show_help: - parser.print_help() - return 0 - - positional_query = " ".join(args.prompt).strip() if args.prompt else None - if args.query and positional_query: - parser.error("pass the query either positionally or via --query, not both") - - query = args.query or positional_query - if not query and not args.list_tools: - parser.print_help() - print("\nNo query provided; pass --query to run the legacy agent entry point.") - return 0 - - main( - query=query, - model=args.model, - api_key=args.api_key, - base_url=args.base_url, - max_turns=args.max_turns, - enabled_toolsets=args.enabled_toolsets, - disabled_toolsets=args.disabled_toolsets, - list_tools=args.list_tools, - save_trajectories=args.save_trajectories, - save_sample=args.save_sample, - verbose=args.verbose, - log_prefix_chars=args.log_prefix_chars, - ) - return 0 - - def main( query: str = None, model: str = "", api_key: str = None, base_url: str = "", max_turns: int = 10, enabled_toolsets: str = None, disabled_toolsets: str = None, list_tools: bool = False, @@ -1647,7 +1557,9 @@ def main( if __name__ == "__main__": - raise SystemExit(legacy_cli_main()) + from agent.legacy_cli import main as _legacy_cli_main + + raise SystemExit(_legacy_cli_main(run=main)) # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- diff --git a/tests/agent/test_legacy_cli.py b/tests/agent/test_legacy_cli.py new file mode 100644 index 0000000000..ba664fa7be --- /dev/null +++ b/tests/agent/test_legacy_cli.py @@ -0,0 +1,58 @@ +"""The packaged ``hermes-agent`` console script honours argv (#54648). + +A console script calls its target with no arguments; these tests go through the +target named in pyproject ``[project.scripts]`` exactly the way pip's wrapper does. +""" + +from __future__ import annotations + +import importlib +import sys +import tomllib +from pathlib import Path + +import pytest + +import run_agent + + +def _run_console_script(monkeypatch, *argv: str): + pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml" + module, func = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["scripts"]["hermes-agent"].split(":") + monkeypatch.setattr(sys, "argv", ["hermes-agent", *argv]) + try: + return getattr(importlib.import_module(module), func)() + except SystemExit as exc: + return exc.code + + +@pytest.mark.parametrize("argv", [("--help",), ("-h",), ("--version",), ()]) +def test_metadata_invocations_never_start_an_agent(argv, monkeypatch, capsys): + def _no_agent(**_kwargs): + raise AssertionError("a metadata invocation built an agent") + + monkeypatch.setattr(run_agent, "AIAgent", _no_agent) + + assert _run_console_script(monkeypatch, *argv) in (0, None) + out = capsys.readouterr().out + assert "usage: hermes-agent" in out or out.startswith("Hermes Agent v") + + +def test_query_and_runner_options_reach_the_agent(monkeypatch, capsys): + seen = {} + + class _Agent: + def __init__(self, **kwargs): + seen["init"] = kwargs + + def run_conversation(self, query): + seen["query"] = query + return {"completed": True, "api_calls": 1, "messages": [], "final_response": "ok"} + + monkeypatch.setattr(run_agent, "AIAgent", _Agent) + + assert _run_console_script(monkeypatch, "--query", "hello there", "--max-turns", "3", + "--disabled-toolsets", "web") in (0, None) + assert seen["query"] == "hello there" + assert seen["init"]["max_iterations"] == 3 + assert seen["init"]["disabled_toolsets"] == ["web"] diff --git a/tests/agent/test_legacy_cli_entrypoint.py b/tests/agent/test_legacy_cli_entrypoint.py deleted file mode 100644 index 8a028a2738..0000000000 --- a/tests/agent/test_legacy_cli_entrypoint.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import tomllib -from pathlib import Path - -import run_agent - - -def _raise_if_agent_starts(**_kwargs): - raise AssertionError("legacy metadata commands must not start the agent") - - -def test_legacy_cli_version_does_not_start_agent(monkeypatch, capsys): - monkeypatch.setattr(run_agent, "main", _raise_if_agent_starts) - - assert run_agent.legacy_cli_main(["--version"]) == 0 - - output = capsys.readouterr().out - assert "Hermes Agent v" in output - - -def test_legacy_cli_help_does_not_start_agent(monkeypatch, capsys): - monkeypatch.setattr(run_agent, "main", _raise_if_agent_starts) - - assert run_agent.legacy_cli_main(["--help"]) == 0 - - output = capsys.readouterr().out - assert "usage: hermes-agent" in output - - -def test_legacy_cli_no_args_is_side_effect_free(monkeypatch, capsys): - monkeypatch.setattr(run_agent, "main", _raise_if_agent_starts) - - assert run_agent.legacy_cli_main([]) == 0 - - output = capsys.readouterr().out - assert "usage: hermes-agent" in output - assert "No query provided" in output - - -def test_legacy_cli_query_dispatches_to_existing_runner(monkeypatch): - calls = {} - - def fake_main(**kwargs): - calls.update(kwargs) - - monkeypatch.setattr(run_agent, "main", fake_main) - - assert run_agent.legacy_cli_main( - [ - "--query", - "hello from the legacy entry point", - "--model", - "provider/model", - "--max-turns", - "3", - "--enabled-toolsets", - "web,terminal", - "--verbose", - ] - ) == 0 - - assert calls == { - "query": "hello from the legacy entry point", - "model": "provider/model", - "api_key": None, - "base_url": "", - "max_turns": 3, - "enabled_toolsets": "web,terminal", - "disabled_toolsets": None, - "list_tools": False, - "save_trajectories": False, - "save_sample": False, - "verbose": True, - "log_prefix_chars": 20, - } - - -def test_packaged_hermes_agent_script_uses_safe_wrapper(): - repo_root = Path(__file__).resolve().parents[2] - pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text(encoding="utf-8")) - - assert pyproject["project"]["scripts"]["hermes-agent"] == "run_agent:legacy_cli_main" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 68050f5ed7..075005479e 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -34,6 +34,10 @@ hermes [global-options] [subcommand/options] | `--cli` | Force the classic prompt_toolkit REPL. Use this to override `display.interface: tui` for a single invocation. | | `--dev` | With `--tui`: run the TypeScript sources directly via `tsx` instead of the prebuilt bundle (for TUI contributors). | +### `hermes-agent` (legacy single-query runner) + +The install also ships `hermes-agent`, a minimal runner that sends one query and exits: `hermes-agent --query "summarize README.md"` (or `hermes-agent "summarize README.md"`). `hermes-agent --help` lists its options (`--model`, `--base-url`, `--max-turns`, `--enabled-toolsets`, `--disabled-toolsets`, `--list-tools`, `--save-trajectories`, …) and `hermes-agent --version` prints the version; neither starts the agent. Run with no query, it prints the same help and exits. For anything else use `hermes` (`hermes -z ` is the scripted one-shot). + ## Top-level commands | Command | Purpose |