fix(cli): move the hermes-agent argv layer into agent/legacy_cli
Follow-up to the salvaged wrapper: the argument layer now lives in a topical sibling (agent/legacy_cli.py) instead of growing the run_agent facade, and `python run_agent.py` (the installer's PATH launcher for hermes-agent) routes through the same parser instead of fire, so `--version` and a bare invocation no longer run the demo turn there either. - --help/-h/--version use argparse's own exit path; options carry help text - the runner is injected (`run=`) so `python run_agent.py` does not import run_agent a second time - tests trimmed to two invariants that resolve the console-script target from pyproject exactly like pip's wrapper (red on main, green here) - docs: hermes-agent section in the CLI reference
This commit is contained in:
81
agent/legacy_cli.py
Normal file
81
agent/legacy_cli.py
Normal file
@@ -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
|
||||
@@ -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]
|
||||
|
||||
94
run_agent.py
94
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) ----
|
||||
|
||||
58
tests/agent/test_legacy_cli.py
Normal file
58
tests/agent/test_legacy_cli.py
Normal file
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -34,6 +34,10 @@ hermes [global-options] <command> [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 <prompt>` is the scripted one-shot).
|
||||
|
||||
## Top-level commands
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
Reference in New Issue
Block a user