Files
hermes-agent/hermes_cli/goal_command.py

195 lines
7.8 KiB
Python

"""The CLI's /goal semantics, shared by every goal command surface.
Adapters supply authorization and schedule the returned prompt; this module alone
parses subcommands and mutates goal state. It never changes conversation history.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Callable
from hermes_cli import goals
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class GoalCommandResult:
output: str
prompt: str | None = None
kickoff: bool = False
clear_pending: str | None = None
error: bool = False
def _english(key, default, **values):
return default.format(**values)
def _status(mgr, arg, render):
return GoalCommandResult(mgr.status_line())
def _show(mgr, arg, render):
return GoalCommandResult(f"{mgr.status_line()}\n{mgr.render_contract()}")
def _pause(mgr, arg, render):
state = mgr.pause(reason="user-paused")
return GoalCommandResult(
render("gateway.goal.paused", "⏸ Goal paused: {goal}", goal=state.goal) if state
else render("gateway.goal.no_goal_set", "No goal set."),
clear_pending="pause" if state else None,
)
def _resume(mgr, arg, render):
state = mgr.resume()
if state is None:
return GoalCommandResult(render("gateway.goal.no_resume", "No goal to resume."))
return GoalCommandResult(render("gateway.goal.resumed", "▶ Goal resumed: {goal}", goal=state.goal),
prompt=mgr.next_continuation_prompt())
def _clear(mgr, arg, render):
had = mgr.has_goal()
mgr.clear()
return GoalCommandResult(render("gateway.goal_cleared", "✓ Goal cleared.") if had else
render("gateway.no_active_goal", "No active goal."), clear_pending="clear")
def _unwait(mgr, arg, render):
return GoalCommandResult("▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting()
else "No wait barrier set.")
def _wait(mgr, arg):
if not arg:
return GoalCommandResult("Usage: /goal wait <pid> [reason]", error=True)
tokens = arg.split(None, 1)
try:
pid = int(tokens[0])
except ValueError:
return GoalCommandResult("/goal wait: <pid> must be an integer process id.", error=True)
reason = tokens[1].strip() if len(tokens) > 1 else ""
mgr.wait_on(pid, reason=reason)
suffix = f" ({reason})" if reason else ""
return GoalCommandResult(f"⏳ Goal parked on pid {pid}{suffix}. Loop pauses until it exits.")
def _gate_add(mgr, arg):
gate = mgr.add_gate(arg)
return GoalCommandResult(f"⚿ Gate added: $ {gate.command} "
f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). "
"It must pass before the goal can complete.")
def _gate_remove(mgr, arg):
return GoalCommandResult(f"✓ Gate removed: $ {mgr.remove_gate(int(arg))}")
def _gate_clear(mgr, arg):
count = mgr.clear_gates()
return GoalCommandResult(f"✓ Cleared {count} gate{'s' if count != 1 else ''}.")
_GATE_HANDLERS = {"add": _gate_add, "remove": _gate_remove, "rm": _gate_remove, "clear": _gate_clear}
_EXACT_HANDLERS = {
"": _status, "status": _status, "show": _show, "pause": _pause,
"resume": _resume, "clear": _clear, "stop": _clear, "done": _clear, "unwait": _unwait,
}
def _gate(mgr, arg, authorize_gate):
if not arg or arg.lower() == "list":
return GoalCommandResult(mgr.render_gates())
tokens = arg.split(None, 1)
verb, rest = tokens[0].lower(), tokens[1].strip() if len(tokens) > 1 else ""
handler = _GATE_HANDLERS.get(verb)
if handler is None or (verb == "clear" and rest) or (verb != "clear" and not rest):
return GoalCommandResult("Usage: /goal gate [list | add <command> | remove <N> | clear]", error=True)
# Gates run shell commands without a later approval. The adapter must explicitly
# authorize creation; recovery commands remain available to non-admin senders.
if verb == "add" and (denial := authorize_gate()):
return GoalCommandResult(denial, error=True)
try:
return handler(mgr, rest)
except (RuntimeError, ValueError, IndexError) as exc:
operation = "remove" if verb == "rm" else verb
return GoalCommandResult(f"/goal gate {operation}: {exc}", error=True)
def _set(mgr, arg, *, drafting, last_user_message, render, progress):
if drafting:
if not arg:
return GoalCommandResult("Usage: /goal draft <objective in plain language>", error=True)
if progress is not None:
progress("Drafting completion contract…")
try:
contract = goals.draft_contract(arg)
except Exception as exc:
logger.debug("goal draft failed: %s", exc)
contract = None
headline = arg
else:
headline, contract = goals.parse_contract(arg)
contract = contract if not contract.is_empty() else None
state = mgr.set(headline or arg, contract=contract)
output = render("gateway.goal.set", "⊙ Goal set ({budget}-turn budget): {goal}",
budget=state.max_turns, goal=state.goal)
if state.has_contract():
label = "Drafted completion contract:" if drafting else "Completion contract:"
output += f"\n{label}\n{state.contract.render_block()}"
if drafting:
output += ("\nTighten any field by re-setting the goal with inline lines "
"(e.g. verify: <command>), then /goal resume. Use /goal show to review."
if state.has_contract() else
"\nCouldn't draft a contract (aux model unavailable) — running as a "
"free-form goal. The per-turn judge still applies.")
else:
against = " against the contract above" if state.has_contract() else ""
output += (f"\nAfter each turn, a judge model checks if the goal is done{against}. "
"Hermes keeps working until it is, you pause/clear it, or the budget is "
"exhausted. Use /goal status, /goal show, /goal pause, /goal resume, /goal clear.")
return GoalCommandResult(output, goals.goal_kick_prompt(state.goal, last_user_message), kickoff=True)
def is_goal_control(arg: str) -> bool:
"""Whether this command controls an existing goal rather than replacing it."""
normalized = arg.strip().lower()
return normalized in _EXACT_HANDLERS or normalized.split(None, 1)[0] in {"wait", "gate"}
def dispatch_goal_command(
mgr: goals.GoalManager, arg: str, *, authorize_gate: Callable[[], str | None],
last_user_message=None, render: Callable = _english,
progress: Callable[[str], None] | None = None,
) -> GoalCommandResult:
"""Apply one command. ``authorize_gate`` returns a denial or None (explicit approval).
Synchronous like GoalManager: async adapters run this off-loop with copied
ContextVars so draft credentials and persisted I/O stay in the caller's profile.
"""
arg = arg.strip()
tokens = arg.split(None, 1)
verb = tokens[0].lower() if tokens else ""
rest = tokens[1].strip() if len(tokens) > 1 else ""
prefix = "Invalid goal"
try:
if handler := _EXACT_HANDLERS.get(arg.lower()):
return handler(mgr, "", render)
if verb == "wait":
prefix = "/goal wait"
return _wait(mgr, rest)
if verb == "gate":
prefix = "/goal gate"
return _gate(mgr, rest, authorize_gate)
return _set(mgr, rest if verb == "draft" else arg,
drafting=verb == "draft", last_user_message=last_user_message,
render=render, progress=progress)
except (RuntimeError, ValueError, IndexError) as exc:
output = (render("gateway.goal.invalid", "Invalid goal: {error}", error=str(exc))
if prefix == "Invalid goal" else f"{prefix}: {exc}")
return GoalCommandResult(output, error=True)