From 7c8c031f60da25801f35512072c5c89c652118f3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:44:38 -0700 Subject: [PATCH] feat: add `hermes -z ` one-shot mode (#15702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add `hermes -z ` one-shot mode Top-level flag that runs a single prompt and prints ONLY the final response text to stdout. No banner, no spinner, no tool previews, no session_id line — stdout is machine-readable, stderr is silent. Tools, memory, rules, and AGENTS.md in the CWD are loaded as normal. Approvals are auto-bypassed (sets HERMES_YOLO_MODE=1 for the call). Bypasses cli.py entirely — goes straight to AIAgent.chat(). * feat(oneshot): handle interactive-callback gaps explicitly Document (and where needed, patch) the interactive surfaces that have no user to answer in oneshot mode: - clarify — inject a callback that tells the agent to pick the best default and continue (previously returned a generic 'not available in this execution context' error that wastes a tool call) - sudo password — terminal_tool already gates on HERMES_INTERACTIVE (we don't set it); sudo fails gracefully - shell hooks — HERMES_ACCEPT_HOOKS=1 auto-approves; also falls back to deny on non-tty stdin - dangerous cmd — HERMES_YOLO_MODE=1 short-circuits before input() - secret capture— tool returns gracefully when no callback wired Live-tested: agent asked clarify(['red','blue']) and got 'red' back, replied with only 'red'. --- hermes_cli/main.py | 20 +++++++ hermes_cli/oneshot.py | 127 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 hermes_cli/oneshot.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7284f8814..4c5a200a1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6835,6 +6835,19 @@ For more help on a command: parser.add_argument( "--version", "-V", action="store_true", help="Show version and exit" ) + parser.add_argument( + "-z", + "--oneshot", + metavar="PROMPT", + default=None, + help=( + "One-shot mode: send a single prompt and print ONLY the final " + "response text to stdout. No banner, no spinner, no tool " + "previews, no session_id line. Tools, memory, rules, and " + "AGENTS.md in the CWD are loaded as normal; approvals are " + "auto-bypassed. Intended for scripts / pipes." + ), + ) parser.add_argument( "--resume", "-r", @@ -9115,6 +9128,13 @@ Examples: exc_info=True, ) + # Handle top-level --oneshot / -z: single-shot mode, stdout = final + # response only, nothing else. Bypasses cli.py entirely. + if getattr(args, "oneshot", None): + from hermes_cli.oneshot import run_oneshot + + sys.exit(run_oneshot(args.oneshot)) + # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: args.command = "chat" diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py new file mode 100644 index 000000000..70ccc9a92 --- /dev/null +++ b/hermes_cli/oneshot.py @@ -0,0 +1,127 @@ +"""Oneshot (-z) mode: send a prompt, get the final content block, exit. + +Bypasses cli.py entirely. No banner, no spinner, no session_id line, +no stderr chatter. Just the agent's final text to stdout. + +Toolsets = whatever the user has configured for "cli" in `hermes tools`. +Rules / memory / AGENTS.md / preloaded skills = same as a normal chat turn. +Approvals = auto-bypassed (HERMES_YOLO_MODE=1 is set for the call). +Working directory = the user's CWD (AGENTS.md etc. resolve from there as usual). +""" + +from __future__ import annotations + +import logging +import os +import sys +from contextlib import redirect_stderr, redirect_stdout + + +def run_oneshot(prompt: str) -> int: + """Execute a single prompt and print only the final content block. + + Returns the exit code. Caller should sys.exit() with the return. + """ + # Silence every stdlib logger for the duration. AIAgent, tools, and + # provider adapters all log to stderr through the root logger; file + # handlers added by setup_logging() keep working (they're attached to + # the root logger's handler list, not affected by level), but no + # bytes reach the terminal. + logging.disable(logging.CRITICAL) + + # Auto-approve any shell / tool approvals. Non-interactive by + # definition — a prompt would hang forever. + os.environ["HERMES_YOLO_MODE"] = "1" + os.environ["HERMES_ACCEPT_HOOKS"] = "1" + + # Redirect stderr AND stdout to devnull for the entire call tree. + # We'll print the final response to the real stdout at the end. + real_stdout = sys.stdout + devnull = open(os.devnull, "w") + + try: + with redirect_stdout(devnull), redirect_stderr(devnull): + response = _run_agent(prompt) + finally: + try: + devnull.close() + except Exception: + pass + + if response: + real_stdout.write(response) + if not response.endswith("\n"): + real_stdout.write("\n") + real_stdout.flush() + return 0 + + +def _run_agent(prompt: str) -> str: + """Build an AIAgent exactly like a normal CLI chat turn would, then + run a single conversation. Returns the final response string.""" + # Imports are local so they don't run when hermes is invoked for + # other commands (keeps top-level CLI startup cheap). + from hermes_cli.config import load_config + from hermes_cli.runtime_provider import resolve_runtime_provider + from hermes_cli.tools_config import _get_platform_tools + from run_agent import AIAgent + + cfg = load_config() + runtime = resolve_runtime_provider() + + # Resolve the model the user has configured for normal chat use. + model_cfg = cfg.get("model") or {} + if isinstance(model_cfg, str): + model = model_cfg + else: + model = model_cfg.get("default") or model_cfg.get("model") or "" + + # Pull in whatever toolsets the user has enabled for "cli". + # sorted() gives stable ordering; set→list for AIAgent's signature. + toolsets_list = sorted(_get_platform_tools(cfg, "cli")) + + agent = AIAgent( + api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), + model=model, + enabled_toolsets=toolsets_list, + quiet_mode=True, + platform="cli", + credential_pool=runtime.get("credential_pool"), + # Interactive callbacks are intentionally NOT wired beyond this + # one. In oneshot mode there's no user sitting at a terminal: + # - clarify → returns a synthetic "pick a default" instruction + # so the agent continues instead of stalling on + # the tool's built-in "not available" error + # - sudo password prompt → terminal_tool gates on + # HERMES_INTERACTIVE which we never set + # - shell-hook approval → auto-approved via HERMES_ACCEPT_HOOKS=1 + # (set above); also falls back to deny on non-tty + # - dangerous-command approval → bypassed via HERMES_YOLO_MODE=1 + # - skill secret capture → returns gracefully when no callback set + clarify_callback=_oneshot_clarify_callback, + ) + + # Belt-and-braces: make sure AIAgent doesn't invoke any streaming + # display callbacks that would bypass our stdout capture. + agent.suppress_status_output = True + agent.stream_delta_callback = None + agent.tool_gen_callback = None + + return agent.chat(prompt) or "" + + +def _oneshot_clarify_callback(question: str, choices=None) -> str: + """Clarify is disabled in oneshot mode — tell the agent to pick a + default and proceed instead of stalling or erroring.""" + if choices: + return ( + f"[oneshot mode: no user available. Pick the best option from " + f"{choices} using your own judgment and continue.]" + ) + return ( + "[oneshot mode: no user available. Make the most reasonable " + "assumption you can and continue.]" + )