Skip to content
Merged
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
81 changes: 81 additions & 0 deletions agent/legacy_cli.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ build-backend = "setuptools.build_meta"

[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-agent = "agent.legacy_cli:main"
hermes-acp = "acp_adapter.entry:main"

[tool.uv]
Expand Down
5 changes: 3 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,8 +1557,9 @@ def main(


if __name__ == "__main__":
import fire
fire.Fire(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) ----
Expand Down
58 changes: 58 additions & 0 deletions tests/agent/test_legacy_cli.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading