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:
+28
-144
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user