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.
This commit is contained in:
Teknium
2026-09-07 00:16:09 -07:00
parent 3c9c225425
commit ebf2473325
12 changed files with 456 additions and 366 deletions
+28 -144
View File
@@ -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 / <new goal>.
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 <pid> [reason] — park the loop on a background process."""
if not wait_arg:
return "Usage: /goal wait <pid> [reason]"
wtokens = wait_arg.split(None, 1)
try:
pid = int(wtokens[0])
except ValueError:
return "/goal wait: <pid> 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 <command> | remove <N> | 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 <command> | remove <N> | 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 <objective>``."""
drafting = lower.startswith("draft")
if drafting:
objective = args[len("draft"):].strip()
if not objective:
return "Usage: /goal draft <objective in plain language>"
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
+9
View File
@@ -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
+16 -157
View File
@@ -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 <objective in plain language>")
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 <text> 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 <pid> [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 <pid> [reason]")
wtokens = wait_arg.split(None, 1)
try:
pid = int(wtokens[0])
except ValueError:
return _cp(" /goal wait: <pid> 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 <command> | remove <N> | 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 <command> | remove <N> | 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: <command>), 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] <prompt> [--times N]
[--until <cond>]`` starts one; ``status | pause | resume | stop`` control it."""
+188
View File
@@ -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 <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 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)
+15 -11
View File
@@ -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)
+108
View File
@@ -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'
+61
View File
@@ -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
+1 -2
View File
@@ -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."
+1 -11
View File
@@ -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
+23 -40
View File
@@ -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):
+1
View File
@@ -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))
+5 -1
View File
@@ -71,7 +71,11 @@ What you'll see:
| `/goal gate remove <N>` | 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 <text>` 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