mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
Makes hermes -z usable by sweeper without mutating user config. - Top-level -m/--model and --provider flags that apply to -z/--oneshot (mirrors hermes chat's plumbing). - HERMES_INFERENCE_MODEL env var as the parallel to HERMES_INFERENCE_PROVIDER for CI / scripted invocations. - resolve_runtime_provider() gets the requested provider; when --model is given without --provider, detect_provider_for_model() auto-selects the provider that serves it (same semantic as /model in an interactive session). - --provider without --model errors out with exit 2 — carrying a config model across to a different provider is usually wrong, and silently picking the provider's catalog default hides the mismatch. Config defaults still used when both flags are omitted (existing behavior). Validation (all live against OpenRouter): -z 'x' ....................... uses config default (opus-4.7) -z 'x' --model haiku-4.5 ..... haiku-4.5 via auto-detected openrouter -z 'x' --model ... --provider pair as given HERMES_INFERENCE_MODEL=... -z haiku-4.5 via env var -z 'x' --provider anthropic .. exits 2 with error to stderr
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
"""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).
|
|
|
|
Model / provider selection mirrors `hermes chat`:
|
|
- Both optional. If omitted, use the user's configured default.
|
|
- If both given, pair them exactly as given.
|
|
- If only --model given, auto-detect the provider that serves it.
|
|
- If only --provider given, error out (ambiguous — caller must pick a model).
|
|
|
|
Env var fallbacks (used when the corresponding arg is not passed):
|
|
- HERMES_INFERENCE_MODEL
|
|
- HERMES_INFERENCE_PROVIDER (already read by resolve_runtime_provider)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
from typing import Optional
|
|
|
|
|
|
def run_oneshot(
|
|
prompt: str,
|
|
model: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
) -> int:
|
|
"""Execute a single prompt and print only the final content block.
|
|
|
|
Args:
|
|
prompt: The user message to send.
|
|
model: Optional model override. Falls back to HERMES_INFERENCE_MODEL
|
|
env var, then config.yaml's model.default / model.model.
|
|
provider: Optional provider override. Falls back to
|
|
HERMES_INFERENCE_PROVIDER env var, then config.yaml's model.provider,
|
|
then "auto".
|
|
|
|
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)
|
|
|
|
# --provider without --model is ambiguous: carrying the user's configured
|
|
# model across to a different provider is usually wrong (that provider may
|
|
# not host it), and silently picking the provider's catalog default hides
|
|
# the mismatch. Require the caller to be explicit. Validate BEFORE the
|
|
# stderr redirect so the message actually reaches the terminal.
|
|
env_model_early = os.getenv("HERMES_INFERENCE_MODEL", "").strip()
|
|
if provider and not ((model or "").strip() or env_model_early):
|
|
sys.stderr.write(
|
|
"hermes -z: --provider requires --model (or HERMES_INFERENCE_MODEL). "
|
|
"Pass both explicitly, or neither to use your configured defaults.\n"
|
|
)
|
|
return 2
|
|
|
|
# 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, model=model, provider=provider)
|
|
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,
|
|
model: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
) -> 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.models import detect_provider_for_model
|
|
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()
|
|
|
|
# Resolve effective model: explicit arg → env var → config.
|
|
model_cfg = cfg.get("model") or {}
|
|
if isinstance(model_cfg, str):
|
|
cfg_model = model_cfg
|
|
else:
|
|
cfg_model = model_cfg.get("default") or model_cfg.get("model") or ""
|
|
|
|
env_model = os.getenv("HERMES_INFERENCE_MODEL", "").strip()
|
|
effective_model = (model or "").strip() or env_model or cfg_model
|
|
|
|
# Resolve effective provider: explicit arg → (auto-detect from model if
|
|
# model was explicit) → env / config (handled inside resolve_runtime_provider).
|
|
#
|
|
# When --model is given without --provider, auto-detect the provider that
|
|
# serves that model — same semantic as `/model <name>` in an interactive
|
|
# session. Without this, resolve_runtime_provider() would fall back to
|
|
# the user's configured default provider, which may not host the model
|
|
# the caller just asked for.
|
|
effective_provider = (provider or "").strip() or None
|
|
if effective_provider is None and (model or env_model):
|
|
# Only auto-detect when the model was explicitly requested via arg or
|
|
# env var (not when it came from config — that's the "use my defaults"
|
|
# path and the configured provider is already correct).
|
|
explicit_model = (model or "").strip() or env_model
|
|
if explicit_model:
|
|
cfg_provider = ""
|
|
if isinstance(model_cfg, dict):
|
|
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
|
current_provider = (
|
|
cfg_provider
|
|
or os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
|
or "auto"
|
|
)
|
|
detected = detect_provider_for_model(explicit_model, current_provider)
|
|
if detected:
|
|
effective_provider, effective_model = detected
|
|
|
|
runtime = resolve_runtime_provider(
|
|
requested=effective_provider,
|
|
target_model=effective_model or None,
|
|
)
|
|
|
|
# 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=effective_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.]"
|
|
)
|