From ebf2473325d2c293aa9912e6bdc0a72d7ea9864c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:16:09 -0700 Subject: [PATCH] refactor: share CLI goal commands across interactive surfaces Keep parsing, contracts, gates and persisted goal mutations in one dispatcher. Adapters retain authorization, rendering and scheduling; TUI drafting resolves the target session profile off the RPC reader. Document ACP as unsupported rather than implying a goal loop exists. --- gateway/slash_commands_goals.py | 172 ++++---------------- hermes_cli/AGENTS.md | 9 ++ hermes_cli/cli_commands_mixin.py | 173 ++------------------ hermes_cli/goal_command.py | 188 ++++++++++++++++++++++ tests/cli/test_cli_goal_kick_prompt.py | 26 +-- tests/hermes_cli/test_goal_dispatch.py | 108 +++++++++++++ tests/tui_gateway/test_goal_command.py | 61 +++++++ tests/tui_gateway/test_session_control.py | 3 +- tui_gateway/methods_session_control.py | 12 +- tui_gateway/methods_tools.py | 63 +++----- tui_gateway/server.py | 1 + website/docs/user-guide/features/goals.md | 6 +- 12 files changed, 456 insertions(+), 366 deletions(-) create mode 100644 hermes_cli/goal_command.py create mode 100644 tests/hermes_cli/test_goal_dispatch.py diff --git a/gateway/slash_commands_goals.py b/gateway/slash_commands_goals.py index 607c9c6d3b..615680e5c5 100644 --- a/gateway/slash_commands_goals.py +++ b/gateway/slash_commands_goals.py @@ -35,40 +35,35 @@ class GatewayGoalCommandsMixin: """Autonomy-loop gateway commands: /goal, /subgoal, /heartbeat, /loop, /refine, /review.""" async def _handle_goal_command(self, event: MessageEvent) -> str: - """Handle /goal: status / show / unwait / clear / pause / resume / wait / gate / . + from hermes_cli.goal_command import dispatch_goal_command + from hermes_cli.goals import last_user_message_from_db - Setting a new goal queues the goal text as the next turn so the agent starts immediately; - the post-turn continuation hook takes over after. - """ - args = (event.get_command_args() or "").strip() - lower = args.lower() mgr, _session_entry = await self._get_goal_manager_for_event(event) if mgr is None: return t("gateway.goal.unavailable") - if not args or lower == "status": - return mgr.status_line() - if lower == "show": - return f"{mgr.status_line()}\n{mgr.render_contract()}" - if lower == "unwait": - return "▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting() else "No wait barrier set." - if lower in {"clear", "stop", "done"}: - had = mgr.has_goal() - mgr.clear() - self._clear_goal_continuations(event, "clear") - return t("gateway.goal_cleared") if had else t("gateway.no_active_goal") - if lower == "pause": - state = mgr.pause(reason="user-paused") - if state is None: - return t("gateway.goal.no_goal_set") - self._clear_goal_continuations(event, "pause") - return t("gateway.goal.paused", goal=state.goal) - if lower == "resume": - return self._goal_resume(mgr, event) - # Verb-prefixed forms take the remainder as their argument. - for verb, handler in (("wait", self._goal_wait), ("gate", self._goal_gate)): - if lower == verb or lower.startswith(verb + " "): - return handler(mgr, args[len(verb):].strip(), event) - return await self._goal_set(mgr, args, lower, event) + + def authorize_gate(): + if not self._resume_caller_is_admin(event.source): + return ("⛔ /goal gate add requires an explicitly configured " + "gateway admin (allow_admin_from for DMs, " + "group_allow_admin_from for groups).") + return None + + def dispatch(): + return dispatch_goal_command( + mgr, event.get_command_args() or "", authorize_gate=authorize_gate, + render=lambda key, default, **values: t(key, **values), + last_user_message=last_user_message_from_db(getattr(mgr, "session_id", None)), + ) + + # Drafting resolves profile-scoped credentials. Keep ContextVars across the + # executor hop; manager I/O must also stay off the messaging event loop. + result = await self._run_in_executor_with_context(dispatch) + if result.clear_pending: + self._clear_goal_continuations(event, result.clear_pending) + if result.prompt: + self._enqueue_goal_turn(event, result.prompt, label="command enqueue", kickoff=result.kickoff) + return result.output def _clear_goal_continuations(self, event: MessageEvent, verb: str) -> None: try: @@ -79,15 +74,15 @@ class GatewayGoalCommandsMixin: logger.debug("goal %s: pending continuation cleanup failed: %s", verb, exc) def _enqueue_goal_turn( - self, event: MessageEvent, text: str, *, label: str, kickoff: bool, route=None + self, event: MessageEvent, text: str, *, label: str, kickoff: bool ) -> None: """Enqueue *text* as the next turn through the adapter FIFO (the post-turn judge's path). A kickoff keeps the triggering message id / channel prompt; a resume continuation carries - none. *route* is a pre-resolved ``(adapter, quick_key)``. Best-effort: failures only logged. + none. Best-effort: failures only logged. """ try: - adapter, quick_key = route or self._adapter_and_key_for(event) + adapter, quick_key = self._adapter_and_key_for(event) if text and adapter and quick_key: turn = MessageEvent( text=text, @@ -100,117 +95,6 @@ class GatewayGoalCommandsMixin: except Exception as exc: logger.debug("goal %s failed: %s", label, exc) - def _goal_resume(self, mgr, event: MessageEvent) -> str: - state = mgr.resume() - if state is None: - return t("gateway.goal.no_resume") - # Resume must restart work, not just flip persisted state: enqueue the canonical - # continuation so the next turn fires as soon as this reply is delivered. - self._enqueue_goal_turn( - event, mgr.next_continuation_prompt(), label="resume: continuation enqueue", kickoff=False - ) - return t("gateway.goal.resumed", goal=state.goal) - - @staticmethod - def _goal_wait(mgr, wait_arg: str, event: MessageEvent) -> str: - """/goal wait [reason] — park the loop on a background process.""" - if not wait_arg: - return "Usage: /goal wait [reason]" - wtokens = wait_arg.split(None, 1) - try: - pid = int(wtokens[0]) - except ValueError: - return "/goal wait: must be an integer process id." - reason = wtokens[1].strip() if len(wtokens) > 1 else "" - _, err = _mgr_call("/goal wait", lambda: mgr.wait_on(pid, reason=reason)) - if err: - return err - rtxt = f" ({reason})" if reason else "" - return f"⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits." - - def _goal_gate(self, mgr, gate_arg: str, event: MessageEvent) -> str: - """/goal gate [list | add | remove | clear] — deterministic quality gates.""" - gate_lower = gate_arg.lower() - if not gate_arg or gate_lower == "list": - return mgr.render_gates() - if gate_lower.startswith("add "): - # SECURITY: a gate is persisted and later executed with shell=True at every goal turn - # boundary (run_gate), with no approval prompt. Letting an allowed but non-admin sender - # choose that string is authenticated RCE under the Hermes process account — and with - # no admin list configured (the default) every allowed sender is unrestricted. Gate ONLY - # this shell-creating operation behind a real, explicitly-configured admin (the same - # fail-closed check that guards cross-origin /resume); list/remove/clear stay open so - # a non-admin can still recover. - if not self._resume_caller_is_admin(event.source): - return ( - "⛔ /goal gate add requires an explicitly configured " - "gateway admin (allow_admin_from for DMs, " - "group_allow_admin_from for groups)." - ) - gate, err = _mgr_call("/goal gate add", mgr.add_gate, gate_arg[len("add"):].strip()) - if err: - return err - return ( - f"⚿ Gate added: $ {gate.command} " - f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). " - f"It must pass before the goal can complete." - ) - if gate_lower.startswith(("remove ", "rm ")): - removed, err = _mgr_call( - "/goal gate remove", lambda: mgr.remove_gate(int(gate_arg.split(None, 1)[1].strip())), - errors=(RuntimeError, ValueError, IndexError), - ) - return err or f"✓ Gate removed: $ {removed}" - if gate_lower == "clear": - prev, err = _mgr_call("/goal gate clear", mgr.clear_gates, errors=(RuntimeError,)) - return err or f"✓ Cleared {_plural(prev, 'gate')}." - return "Usage: /goal gate [list | add | remove | clear]" - - async def _goal_set(self, mgr, args: str, lower: str, event: MessageEvent) -> str: - """Set a new goal from free text, inline ``field: value`` contract lines, or ``draft ``.""" - drafting = lower.startswith("draft") - if drafting: - objective = args[len("draft"):].strip() - if not objective: - return "Usage: /goal draft " - try: - from hermes_cli.goals import draft_contract - - # _run_in_executor_with_context, not a bare hop: drafting calls the auxiliary LLM, - # whose provider/credential resolution reads the profile secret scope — a - # contextvar a default-executor hop drops. - contract = await self._run_in_executor_with_context(draft_contract, objective) - except Exception as exc: - logger.debug("goal draft failed: %s", exc) - contract = None - args = objective # the goal text is the objective - else: - # Inline `field: value` lines parse into a completion contract; the remaining prose is - # the goal headline. Plain free-form goals (no such lines) behave exactly as before. - from hermes_cli.goals import parse_contract - headline, parsed = parse_contract(args) - args = headline or args - contract = parsed if not parsed.is_empty() else None - try: - state = mgr.set(args, contract=contract) - except ValueError as exc: - return t("gateway.goal.invalid", error=str(exc)) - - # Queue the goal text as an immediate first turn (a short pointer when the user just pasted that - # very text: hermes_cli.goals.goal_kick_prompt); the post-turn hook takes over after. - from hermes_cli.goals import goal_kick_prompt, last_user_message_from_db - kick = goal_kick_prompt(state.goal, last_user_message_from_db(getattr(mgr, "session_id", None))) - self._enqueue_goal_turn( - event, kick, label="kickoff enqueue", kickoff=True, route=self._adapter_and_key_for(event) - ) - - base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal) - if state.has_contract(): - return f"{base}\nCompletion contract:\n{state.contract.render_block()}" - if drafting: - return f"{base}\n(Couldn't draft a contract — running as a free-form goal.)" - return base - async def _handle_heartbeat_command(self, event: MessageEvent) -> str: """Handle /heartbeat (mirror of the CLI handler): the session's one recurring re-entry prompt. The gateway-wide poller injects due heartbeats through the adapter FIFO as diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index bdee473f03..7b7f7b1cc2 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -43,6 +43,15 @@ settings via `save_config_value()` in `cli.py`. **Adding an alias** = add to `al surface updates automatically. Commands that mutate system-prompt state default to deferred invalidation with `--now` opt-in (root invariant). +### Shared goal commands + +`hermes_cli/goal_command.py::dispatch_goal_command` owns `/goal` parsing and manager +mutations. CLI, messaging gateway, TUI/Desktop/dashboard and Desktop goal controls +all delegate there; adapters only resolve sessions, authorize gate creation, render +results, and schedule kickoff/continuation prompts. Async callers preserve ContextVars +when running dispatch off-loop (drafting uses profile-scoped auxiliary credentials). +Do not add a surface-specific goal parser. ACP has no goal command or goal loop yet. + ## Config system (`hermes_cli/config.py`) - **config.yaml option:** add to `DEFAULT_CONFIG`. Bump `_config_version` ONLY to actively diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 146f6d85fd..7061fdcc56 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2183,174 +2183,33 @@ class CLICommandsMixin: # ---- /goal, /loop, /subgoal ----------------------------------------------------------- def _handle_goal_command(self, cmd: str) -> None: - """Dispatch /goal subcommands: set / draft / show / gate / wait / status / pause / resume / clear.""" - arg = _command_arg(cmd) + from hermes_cli.goal_command import dispatch_goal_command + from hermes_cli.goals import last_user_message_content + mgr = self._session_manager(self._get_goal_manager, "Goals") if mgr is None: return - lower = arg.lower() - verb, _, rest = arg.partition(" ") - verb = verb.lower() - rest = rest.strip() - if not arg or lower == "status": - _cp(f" {mgr.status_line()}") - elif lower == "show": - _cp(f" {mgr.status_line()}") - _cp(f" {mgr.render_contract()}") - elif lower.startswith("draft"): - # Expand plain text into a structured completion contract so "done" is evidence-based - # instead of a vibe check. - objective = arg[len("draft"):].strip() - if not objective: - return _cp(" Usage: /goal draft ") - self._handle_goal_draft(objective) - elif lower == "pause": - state = mgr.pause(reason="user-paused") - _cp(f" ⏸ Goal paused: {state.goal}" if state else _dim_line('No goal set.')) - elif lower == "resume": - self._goal_resume(mgr) - elif lower in {"clear", "stop", "done"}: - had = mgr.has_goal() - mgr.clear() - _cp(" ✓ Goal cleared." if had else _dim_line('No active goal.')) - elif verb == "wait": - self._goal_wait(mgr, rest) - elif lower == "unwait": - _cp(" ▶ Wait barrier cleared — goal loop resumes." if mgr.stop_waiting() - else _dim_line('No wait barrier set.')) - elif verb == "gate": - self._goal_gate(mgr, rest) - else: - self._goal_set(mgr, arg) - - def _goal_kick_prompt(self, goal: str) -> str: - """The goal text, or a short pointer when the last user message is essentially that text - (shared rule: ``hermes_cli.goals.goal_kick_prompt``).""" - from hermes_cli.goals import goal_kick_prompt, last_user_message_content - return goal_kick_prompt(goal, last_user_message_content(getattr(self, "conversation_history", None))) + result = dispatch_goal_command( + mgr, _command_arg(cmd), authorize_gate=lambda: None, + progress=lambda text: _cp(_dim_line(text)), + last_user_message=last_user_message_content(getattr(self, "conversation_history", None)), + ) + for line in result.output.splitlines(): + _cp(f" {line}") + if result.prompt: + queued = self._kick_goal(result.prompt) + if not result.kickoff: + _cp(_dim_line('Continuing now — taking the next step.' if queued else + 'Send any message to kick off the next step.')) def _kick_goal(self, prompt: str) -> bool: - """Queue ``prompt`` as the next turn so the loop starts without a separate message.""" + """Queue the next turn without mutating cached conversation history.""" try: self._pending_input.put(prompt) return True except Exception: return False - def _goal_resume(self, mgr) -> None: - state = mgr.resume() - if state is None: - return _cp(_dim_line('No goal to resume.')) - _cp(f" ▶ Goal resumed: {state.goal}") - # Resume must restart work, not just flip state: queue the continuation prompt the same - # way /goal queues its kickoff. - # Resume must restart work, not just flip persisted state (#75362): enqueue the canonical - # continuation through the adapter FIFO — the same path the post-turn judge uses — so the next turn - # fires as soon as this reply is delivered. A real user message already queued still preempts - # naturally, and pause/clear's stale-continuation cleanup recognizes it. - # See #75362. - # An `exec` result is display-only — nothing would re-enter the conversation loop until the user - # typed another message. Return a `send` dispatch carrying the canonical continuation prompt so the - # client fires the next turn immediately; `display` keeps the transcript showing the concise - # invocation instead of the model-facing scaffolding. See #75362. - prompt = mgr.next_continuation_prompt() - if prompt and self._kick_goal(prompt): - _cp(_dim_line('Continuing now — taking the next step.')) - else: - _cp(_dim_line('Send any message to kick off the next step.')) - - def _goal_wait(self, mgr, wait_arg: str) -> None: - """/goal wait [reason] — park the loop on a background process (CI / build); - the barrier auto-clears when the PID exits.""" - if not wait_arg: - return _cp(" Usage: /goal wait [reason]") - wtokens = wait_arg.split(None, 1) - try: - pid = int(wtokens[0]) - except ValueError: - return _cp(" /goal wait: must be an integer process id.") - reason = wtokens[1].strip() if len(wtokens) > 1 else "" - if _attempt("/goal wait", (RuntimeError, ValueError), mgr.wait_on, pid, reason=reason) is _FAILED: - return - rtxt = f" ({reason})" if reason else "" - _cp(f" ⏳ Goal parked on pid {pid}{rtxt}. Loop pauses until it exits.") - - def _goal_gate(self, mgr, gate_arg: str) -> None: - """/goal gate [list | add | remove | clear] — shell commands that must pass - before the judge may declare the goal done; a failing gate's output becomes the - continuation prompt.""" - gate_lower = gate_arg.lower() - if not gate_arg or gate_lower == "list": - for line in mgr.render_gates().splitlines(): - _cp(f" {line}") - elif gate_lower.startswith("add "): - gate = _attempt("/goal gate add", (RuntimeError, ValueError), - mgr.add_gate, gate_arg[len("add"):].strip()) - if gate is not _FAILED: - _cp(f" ⚿ Gate added: $ {gate.command} " - f"({gate.max_retries} retries, {gate.timeout_seconds}s timeout). " - f"It must pass before the goal can complete.") - elif gate_lower.startswith("remove ") or gate_lower.startswith("rm "): - removed = _attempt("/goal gate remove", (RuntimeError, ValueError, IndexError), - lambda: mgr.remove_gate(int(gate_arg.split(None, 1)[1].strip()))) - if removed is not _FAILED: - _cp(f" ✓ Gate removed: $ {removed}") - elif gate_lower == "clear": - prev = _attempt("/goal gate clear", RuntimeError, mgr.clear_gates) - if prev is not _FAILED: - _cp(f" ✓ Cleared {_plural(prev, 'gate')}.") - else: - _cp(" Usage: /goal gate [list | add | remove | clear]") - - def _goal_set(self, mgr, arg: str) -> None: - """Set the goal from free text; inline `verify:`/`constraints:`/`boundaries:`/`stop when:` - lines become a completion contract, the remaining prose the headline. Kicks the loop off.""" - from hermes_cli.goals import parse_contract - headline, contract = parse_contract(arg) - state = _attempt("Invalid goal", ValueError, mgr.set, headline or arg, - contract=contract if not contract.is_empty() else None) - if state is _FAILED: - return - self._print_goal_set(state, "Completion contract:") - against = " against the contract above" if state.has_contract() else "" - _cp(_dim_line(f"After 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.")) - self._kick_goal(self._goal_kick_prompt(state.goal)) - - def _print_goal_set(self, state, contract_label: str) -> None: - _cp(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}") - if state.has_contract(): - _cp(_dim_line(contract_label)) - for line in state.contract.render_block().splitlines(): - _cp(f" {line}") - - def _handle_goal_draft(self, objective: str) -> None: - """Draft a structured completion contract from a plain objective and set it as the active - goal. Falls back to a bare goal if the aux model can't produce a contract.""" - from hermes_cli.goals import draft_contract - mgr = self._session_manager(self._get_goal_manager, "Goals") - if mgr is None: - return - _cp(_dim_line('Drafting completion contract…')) - try: - contract = draft_contract(objective) - except Exception as exc: - import logging as _logging - _logging.getLogger(__name__).debug("goal draft failed: %s", exc) - contract = None - state = _attempt("Invalid goal", ValueError, mgr.set, objective, contract=contract) - if state is _FAILED: - return - self._print_goal_set(state, "Drafted completion contract:") - if state.has_contract(): - _cp(_dim_line("Tighten any field by re-setting the goal with inline lines " - "(e.g. verify: ), then /goal resume. Use /goal show to review.")) - else: - _cp(_dim_line("Couldn't draft a contract (aux model unavailable) — running as a " - "free-form goal. The per-turn judge still applies.")) - self._kick_goal(self._goal_kick_prompt(state.goal)) - def _handle_loop_command(self, cmd: str) -> None: """Dispatch /loop — recurring in-session wakeups: ``/loop [interval] [--times N] [--until ]`` starts one; ``status | pause | resume | stop`` control it.""" diff --git a/hermes_cli/goal_command.py b/hermes_cli/goal_command.py new file mode 100644 index 0000000000..9b0953a65c --- /dev/null +++ b/hermes_cli/goal_command.py @@ -0,0 +1,188 @@ +"""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 [reason]", error=True) + tokens = arg.split(None, 1) + try: + pid = int(tokens[0]) + except ValueError: + return GoalCommandResult("/goal wait: 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 | remove | 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 ", 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: ), 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 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) diff --git a/tests/cli/test_cli_goal_kick_prompt.py b/tests/cli/test_cli_goal_kick_prompt.py index 2e15ba44ab..5db46f2a4f 100644 --- a/tests/cli/test_cli_goal_kick_prompt.py +++ b/tests/cli/test_cli_goal_kick_prompt.py @@ -13,29 +13,36 @@ def _cli(history): cli = CLICommandsMixin.__new__(CLICommandsMixin) cli.conversation_history = history cli._pending_input = queue.Queue() + from hermes_cli.goals import GoalManager + cli._get_goal_manager = lambda: GoalManager("kick-test") return cli +def _kick(cli, text): + cli._handle_goal_command("/goal " + text) + return cli._pending_input.get_nowait() + + HANDOFF = "HANDOFF: resume round 3 integration.\n" + "\n".join(f" - step {i}: merge r3-{i} and run its targeted suite, then the full suite" for i in range(8)) def test_goal_that_the_user_just_pasted_kicks_with_a_pointer_not_the_text(): cli = _cli([{"role": "user", "content": HANDOFF + "\n\nGo."}, {"role": "assistant", "content": "ok"}]) - assert cli._goal_kick_prompt(HANDOFF) == GOAL_ALREADY_SEEN_KICK + assert _kick(cli, HANDOFF) == GOAL_ALREADY_SEEN_KICK # block-style content is handled too cli = _cli([{"role": "user", "content": [{"type": "text", "text": HANDOFF}]}]) - assert cli._goal_kick_prompt(HANDOFF) == GOAL_ALREADY_SEEN_KICK + assert _kick(cli, HANDOFF) == GOAL_ALREADY_SEEN_KICK def test_a_new_goal_or_a_goal_from_an_older_turn_is_kicked_verbatim(): - assert _cli([])._goal_kick_prompt("Ship the release") == "Ship the release" + assert _kick(_cli([]), "Ship the release") == "Ship the release" cli = _cli([{"role": "user", "content": "Something unrelated"}]) - assert cli._goal_kick_prompt(HANDOFF) == HANDOFF + assert _kick(cli, HANDOFF) == " ".join(HANDOFF.split()) # only the LAST user message counts: the agent has moved on since an older paste cli = _cli([{"role": "user", "content": HANDOFF}, {"role": "assistant", "content": "done"}, {"role": "user", "content": "now something else"}]) - assert cli._goal_kick_prompt(HANDOFF) == HANDOFF + assert _kick(cli, HANDOFF) == " ".join(HANDOFF.split()) def test_a_short_goal_that_selects_one_option_from_the_last_message_is_kicked_verbatim(): @@ -44,12 +51,12 @@ def test_a_short_goal_that_selects_one_option_from_the_last_message_is_kicked_ve carries the selection; only a near-whole re-paste is replaced by the pointer.""" offer = "I can either ship the API or ship the UI next; which do you want? " * 8 cli = _cli([{"role": "user", "content": offer}]) - assert cli._goal_kick_prompt("ship the API") == "ship the API" - assert cli._goal_kick_prompt("ship the UI") == "ship the UI" + assert _kick(cli, "ship the API") == "ship the API" + assert _kick(cli, "ship the UI") == "ship the UI" # a long goal that is only a minority of a much longer message is also kept verbatim long_goal = "x" * 500 cli = _cli([{"role": "user", "content": long_goal + " " + "y" * 2000}]) - assert cli._goal_kick_prompt(long_goal) == long_goal + assert _kick(cli, long_goal) == long_goal def test_gateway_and_tui_surfaces_use_the_same_rule(tmp_path, monkeypatch): @@ -60,6 +67,3 @@ def test_gateway_and_tui_surfaces_use_the_same_rule(tmp_path, monkeypatch): assert goals.goal_kick_prompt("ship the API", "ship the API or ship the UI? " * 8) == "ship the API" # DB-backed lookup fails safe to "" (goal kicked verbatim) when no session/db assert goals.last_user_message_from_db(None) == "" - import gateway.slash_commands_goals as g, tui_gateway.methods_tools as m # noqa: E401 - import inspect - assert "goal_kick_prompt" in inspect.getsource(g) and "goal_kick_prompt" in inspect.getsource(m) diff --git a/tests/hermes_cli/test_goal_dispatch.py b/tests/hermes_cli/test_goal_dispatch.py new file mode 100644 index 0000000000..5698537b0a --- /dev/null +++ b/tests/hermes_cli/test_goal_dispatch.py @@ -0,0 +1,108 @@ +"""Every goal surface applies the same commands to real persisted state.""" +import asyncio +import os +import queue +from types import SimpleNamespace + +import pytest + +from hermes_cli import goals + + +def _surface(surface, mgr, monkeypatch, prompts=None): + prompts = prompts if prompts is not None else [] + if surface == "cli": + from hermes_cli.cli_commands_mixin import CLICommandsMixin + cli = object.__new__(CLICommandsMixin) + cli._get_goal_manager = lambda: mgr + cli._pending_input = queue.Queue() + cli.conversation_history = [] + def execute(arg): + cli._handle_goal_command('/goal ' + arg) + while not cli._pending_input.empty(): + prompts.append(cli._pending_input.get_nowait()) + return execute + if surface == "gateway": + from gateway.slash_commands_goals import GatewayGoalCommandsMixin + runner = object.__new__(GatewayGoalCommandsMixin) + async def manager(event): + return mgr, None + async def execute(fn, *args): + return fn(*args) + runner._get_goal_manager_for_event = manager + runner._run_in_executor_with_context = execute + runner._adapter_and_key_for = lambda event: (None, None) + runner._enqueue_goal_turn = lambda event, text, **kwargs: prompts.append(text) + runner._resume_caller_is_admin = lambda source: True + return lambda arg: asyncio.run(runner._handle_goal_command(SimpleNamespace( + get_command_args=lambda: arg, source=None))) + from tui_gateway import server + server._sessions[mgr.session_id] = {'session_key': mgr.session_id} + def execute(arg): + result = server._methods['command.dispatch'](1, { + 'session_id': mgr.session_id, 'name': 'goal', 'arg': arg}) + if result.get('result', {}).get('type') == 'send': + prompts.append(result['result']['message']) + return result + return execute + + +@pytest.mark.parametrize('surface', ['cli', 'gateway', 'tui']) +@pytest.mark.parametrize('command', [ + 'show', 'draft', 'draft build it', 'drafting docs', 'wait', 'wait nope', + 'wait {pid} build', 'unwait', 'gate add true', 'gate remove 1', + 'gate clear', 'gate list', 'pause', 'resume', 'clear', 'stop', 'done', + 'build it\nverify: test passes', 'status', '', +]) +def test_surface_goal_state_matches_cli(surface, command, monkeypatch): + monkeypatch.setattr(goals, 'draft_contract', lambda objective: goals.GoalContract()) + goals._DB_CACHE.clear() + command = command.format(pid=os.getpid()) + snapshots = [] + for name in ('cli', surface): + mgr = goals.GoalManager(session_id=name + '-parity-' + surface) + mgr.set('original objective') + mgr.add_gate('original gate') + mgr.wait_on(os.getpid(), reason='existing barrier') + _surface(name, mgr, monkeypatch)(command) + state = goals.load_goal(mgr.session_id) + if state: + from dataclasses import asdict + state = asdict(state) + for key in ('created_at', 'updated_at', 'waiting_since'): + state.pop(key, None) + snapshots.append(state) + assert snapshots[0] == snapshots[1] + + +@pytest.mark.parametrize('surface', ['cli', 'gateway', 'tui']) +@pytest.mark.parametrize('draft_result', ['contract', 'unavailable', 'error']) +def test_drafts_start_work_but_inspection_and_literal_prefixes_do_not_draft( + surface, draft_result, monkeypatch, +): + calls = [] + def draft(objective): + calls.append(objective) + if draft_result == 'error': + raise RuntimeError('aux offline') + return goals.GoalContract(verification='tests pass') if draft_result == 'contract' else None + monkeypatch.setattr(goals, 'draft_contract', draft) + goals._DB_CACHE.clear() + mgr = goals.GoalManager(session_id='draft-' + surface) + prompts = [] + execute = _surface(surface, mgr, monkeypatch, prompts) + execute('draft build it') + state = goals.load_goal(mgr.session_id) + assert state.goal == 'build it' + assert state.has_contract() == (draft_result == 'contract') + assert calls == ['build it'] + assert prompts == ['build it'] + execute('show') + execute('draft') + execute('wait invalid') + assert goals.load_goal(mgr.session_id).goal == 'build it' + assert prompts == ['build it'] + execute('drafting docs') + assert goals.load_goal(mgr.session_id).goal == 'drafting docs' + assert calls == ['build it'] + assert prompts[-1] == 'drafting docs' diff --git a/tests/tui_gateway/test_goal_command.py b/tests/tui_gateway/test_goal_command.py index 14b8effa4d..2f04458e66 100644 --- a/tests/tui_gateway/test_goal_command.py +++ b/tests/tui_gateway/test_goal_command.py @@ -467,3 +467,64 @@ moa: # Bare /moa is usage-only now; switching to a preset is via the model picker. assert "error" in r assert "model_override" not in s + + +@pytest.mark.parametrize("method", ["command.dispatch", "slash.exec"]) +def test_goal_draft_uses_session_profile_without_blocking_rpc_reader( + server, session, monkeypatch, tmp_path, method, +): + from hermes_cli import goals + from hermes_constants import get_hermes_home + import hermes_state + + # Restore call-time profile resolution; conftest pins this constant to one DB. + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH) + sid, key, record = session + secondary = tmp_path / "secondary" + secondary.mkdir() + (secondary / "config.yaml").write_text("goals:\n max_turns: 37\n", encoding="utf-8") + record["profile_home"] = str(secondary) + started, release, returned, replied = (threading.Event() for _ in range(4)) + observed, frames = [], [] + + def draft(objective): + observed.append(get_hermes_home()) + started.set() + assert release.wait(10) + return goals.GoalContract(verification="tests pass") + + def write(frame): + frames.append(frame) + if frame.get("id") == "draft": + replied.set() + return True + + monkeypatch.setattr(goals, "draft_contract", draft) + transport = types.SimpleNamespace(write=write) + params = {"session_id": sid, "name": "goal", "arg": "draft profile objective"} + if method == "slash.exec": + params = {"session_id": sid, "command": "/goal draft profile objective"} + + def dispatch(): + server.dispatch({"id": "draft", "method": method, "params": params}, transport) + returned.set() + + caller = threading.Thread(target=dispatch) + caller.start() + try: + assert started.wait(5) + assert returned.wait(2), "draft blocked the transport reader" + ping = server.dispatch({"id": "ping", "method": "ping", "params": {}}, transport) + assert ping["id"] == "ping" and "result" in ping + finally: + release.set() + caller.join(5) + assert replied.wait(5) + assert observed == [secondary] + assert goals.load_goal(key) is None, "goal leaked into the launch profile" + with server._session_profile_runtime_scope(record): + state = goals.load_goal(key) + assert state.goal == "profile objective" + assert state.max_turns == 37 and state.contract.verification == "tests pass" + result = next(frame["result"] for frame in frames if frame.get("id") == "draft") + assert result["type"] == "send" and result["message"] == state.goal diff --git a/tests/tui_gateway/test_session_control.py b/tests/tui_gateway/test_session_control.py index b7872f8816..c9da204b4b 100644 --- a/tests/tui_gateway/test_session_control.py +++ b/tests/tui_gateway/test_session_control.py @@ -304,13 +304,12 @@ class TestManagerOnlyMutations: action = "subgoal.add" if "text" in args else "subgoal.remove" assert _error(_call(server, "session.control", session_id=sid, action=action, args=args))["code"] == 4004 - def test_goal_unwait_clears_the_real_barrier_without_dispatch(self, server, session, monkeypatch): + def test_goal_unwait_clears_the_real_barrier_through_shared_command(self, server, session): from hermes_cli.goals import GoalManager sid, key, _ = session _save_goal(key) GoalManager(key).wait_for_seconds(60, reason="backoff") - _forbid_dispatch(server, monkeypatch) response = _call(server, "session.control", session_id=sid, action="goal.unwait") assert response["result"]["dispatch"]["output"] == "▶ Wait barrier cleared — goal loop resumes." diff --git a/tui_gateway/methods_session_control.py b/tui_gateway/methods_session_control.py index 4160ecf174..4f9160a886 100644 --- a/tui_gateway/methods_session_control.py +++ b/tui_gateway/methods_session_control.py @@ -27,13 +27,13 @@ _ACTION_COMMAND_MAP: dict[str, tuple[str, str]] = { "goal.pause": ("goal", "pause"), "goal.resume": ("goal", "resume"), "goal.clear": ("goal", "clear"), + "goal.unwait": ("goal", "unwait"), "loop.pause": ("loop", "pause"), "loop.resume": ("loop", "resume"), "loop.stop": ("loop", "stop"), } _MANAGER_ACTIONS = frozenset({ - "goal.unwait", "subgoal.add", "subgoal.remove", "subgoal.clear", @@ -335,21 +335,11 @@ def _dispatch_command(rid, *, session_id: str, name: str, arg: str) -> dict: def _execute_manager_action(session_key: str, action: str, args: dict) -> dict: """Use manager APIs for controls that have no TUI command handler.""" - if action == "goal.unwait": - return _execute_goal_unwait(session_key) if action.startswith("subgoal."): return _execute_subgoal_action(session_key, action, args) return _execute_heartbeat_action(session_key, action) -def _execute_goal_unwait(session_key: str) -> dict: - from hermes_cli.goals import GoalManager - - manager = GoalManager(session_id=session_key) - output = "▶ Wait barrier cleared — goal loop resumes." if manager.stop_waiting() else "No wait barrier set." - return {"result": {"type": "exec", "output": output}} - - def _execute_subgoal_action(session_key: str, action: str, args: dict) -> dict: from hermes_cli.goals import GoalManager diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index a7ea1e5505..60ce491d02 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -672,46 +672,29 @@ def _cmd_steer(rid, params, session, name, arg): def _cmd_goal(rid, params, session, name, arg): - sid_key, goals, err = _session_key_or_err(rid, session, "hermes_cli.goals", "goals") - if err: - return err - try: - max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20) - except Exception: - max_turns = 20 - mgr = goals.GoalManager(session_id=sid_key, default_max_turns=max_turns) - lower = arg.strip().lower() - if not lower or lower == "status": - return _exec_out(rid, mgr.status_line()) - if lower == "pause": - state = mgr.pause(reason="user-paused") - return _exec_out(rid, "No goal set." if state is None else f"⏸ Goal paused: {state.goal}") - if lower == "resume": - state = mgr.resume() - if state is None: - return _exec_out(rid, "No goal to resume.") - # Resume must restart work: `exec` is display-only, so return a `send`; `display` - # keeps model-facing scaffolding out of the transcript. - if not (prompt := mgr.next_continuation_prompt()): - return _exec_out(rid, f"▶ Goal resumed: {state.goal}") - notice = f"▶ Goal resumed: {state.goal}\nContinuing now — taking the next step." - return _ok(rid, {"type": "send", "notice": notice, "message": prompt, "display": "/goal resume"}) - if lower in {"clear", "stop", "done"}: - had = mgr.has_goal() - mgr.clear() - return _exec_out(rid, "✓ Goal cleared." if had else "No active goal.") - # Remaining text = new goal. Client renders `notice`, submits `message`; the post-turn judge takes over. - try: - state = mgr.set(arg) - except ValueError as exc: - return _err(rid, 4004, f"invalid goal: {exc}") - notice = ( - f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" - "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" - "Controls: /goal status · /goal pause · /goal resume · /goal clear") - from hermes_cli.goals import goal_kick_prompt, last_user_message_from_db - kick = goal_kick_prompt(state.goal, last_user_message_from_db(getattr(mgr, "session_id", None))) - return _ok(rid, {"type": "send", "notice": notice, "message": kick}) + with _session_profile_runtime_scope(session or {}): + sid_key, goals, err = _session_key_or_err(rid, session, "hermes_cli.goals", "goals") + if err: + return err + try: + max_turns = int((_load_cfg().get("goals") or {}).get("max_turns", 20) or 20) + except Exception: + max_turns = 20 + mgr = goals.GoalManager(session_id=sid_key, default_max_turns=max_turns) + from hermes_cli.goal_command import dispatch_goal_command + result = dispatch_goal_command( + mgr, arg, authorize_gate=lambda: None, + last_user_message=goals.last_user_message_from_db(sid_key), + ) + if result.error: + return _err(rid, 4004, result.output) + if not result.prompt: + return _exec_out(rid, result.output) + payload = {"type": "send", "notice": result.output, "message": result.prompt} + if not result.kickoff: + payload["notice"] += "\nContinuing now — taking the next step." + payload["display"] = "/goal resume" + return _ok(rid, payload) def _cmd_loop(rid, params, session, name, arg): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 316ad72ba1..6849dcb023 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -171,6 +171,7 @@ _LONG_HANDLERS = frozenset({ "setup.runtime_check", "setup.status", "voice.toggle", "voice.record", "voice.tts", "wake.start", "wake.status", "session.active_list", "session.branch", "session.compress", "session.list", "session.resume", "session.workspace.move", "shell.exec", "skills.manage", "slash.exec", + "command.dispatch", # /goal draft invokes the auxiliary model; never block the RPC reader }) _rpc_pool_workers = max(2, env_int("HERMES_TUI_RPC_POOL_WORKERS", 8)) diff --git a/website/docs/user-guide/features/goals.md b/website/docs/user-guide/features/goals.md index 40949e6493..4c4ab77218 100644 --- a/website/docs/user-guide/features/goals.md +++ b/website/docs/user-guide/features/goals.md @@ -71,7 +71,11 @@ What you'll see: | `/goal gate remove ` | Remove the Nth gate (1-based). | | `/goal gate clear` | Remove all gates. | -Works identically on the CLI and every gateway platform (Telegram, Discord, Slack, Matrix, Signal, WhatsApp, SMS, iMessage, Webhook, API server, and the web dashboard). +The classic CLI, TUI, Desktop, dashboard chat, and messaging gateway use one shared `/goal` command handler. This includes draft/show, inline contracts, wait/unwait, quality gates, and the clear/stop/done aliases. Desktop goal controls use the same handler, too. ACP does not currently advertise or implement `/goal`. + +`/goal draft ` both creates the goal and starts its first turn, including when drafting is unavailable and Hermes falls back to a free-form goal. `draft` is a whole-word subcommand: `/goal drafting docs` keeps `drafting docs` as the literal objective without calling the draft model. + +Messaging platforms retain their access rules: `/goal gate add` requires an explicitly configured gateway admin; listing, removing, and clearing gates remain available for recovery. Rendering and turn scheduling are surface-specific, but command parsing and persisted goal changes are shared. ## Completion contracts