feat: /goal — persistent cross-turn goals (Ralph loop) (#18262)

Add a standing-goal slash command that keeps Hermes working toward a
user-stated objective across turns until it is achieved, paused, or
the turn budget runs out. Our take on the Ralph loop — cf. Codex CLI
0.128.0's /goal.

After each turn, a lightweight auxiliary-model judge call asks 'is
this goal satisfied by the assistant's last response?'. If not, and
we're under the turn budget (default 20), Hermes feeds a continuation
prompt back into the same session as a normal user message. Any real
user message preempts the continuation loop automatically.

Judge failures fail OPEN (continue) so a flaky judge never wedges
progress — the turn budget is the real backstop.

### Commands

- `/goal <text>`    — set a standing goal (kicks off the first turn)
- `/goal` or `/goal status` — show current state
- `/goal pause`    — pause the continuation loop
- `/goal resume`   — resume (resets turn counter)
- `/goal clear`    — drop the goal

Works on both CLI and gateway platforms via the central CommandDef
registry.

### Design invariants preserved

- **Prompt cache**: continuation prompts are regular user-role
  messages appended to history. No system-prompt mutation, no toolset
  swap.
- **Role alternation**: continuation is a user turn, never injected
  mid-tool-loop.
- **Session persistence**: goal state lives in SessionDB.state_meta
  keyed by `goal:<session_id>`, so `/resume` picks it up.
- **Mid-run safety**: on the gateway, `/goal status|pause|clear` are
  allowed mid-run (control-plane only); setting a new goal requires
  `/stop` first so we don't race a second continuation prompt against
  the current turn.

### Files

- `hermes_cli/goals.py` (new, 380 lines) — GoalManager + judge + state
- `hermes_cli/commands.py` — CommandDef entry
- `hermes_cli/config.py` — `goals.max_turns` default
- `hermes_cli/web_server.py` — dashboard category merge
- `cli.py` — /goal handler + post-turn continuation hook in
  process_loop
- `gateway/run.py` — /goal handler + post-turn continuation hook
  wrapping _handle_message_with_agent
- `tests/hermes_cli/test_goals.py` (new, 26 tests) — judge parsing,
  fail-open semantics, lifecycle, persistence, budget exhaustion
- `website/docs/reference/slash-commands.md` — docs entry
This commit is contained in:
Teknium
2026-04-30 23:10:20 -07:00
committed by GitHub
parent 7c6c5619a7
commit 265bd59c1d
8 changed files with 1327 additions and 2 deletions

View File

@@ -4595,6 +4595,17 @@ class GatewayRunner:
if _cmd_def_inner and _cmd_def_inner.name == "kanban":
return await self._handle_kanban_command(event)
# /goal is safe mid-run for status/pause/clear (inspection and
# control-plane only — doesn't interrupt the running turn).
# Setting a new goal text mid-run is rejected with the same
# "wait or /stop" message as /model so we don't race a second
# continuation prompt against the current turn.
if _cmd_def_inner and _cmd_def_inner.name == "goal":
_goal_arg = (event.get_command_args() or "").strip().lower()
if not _goal_arg or _goal_arg in ("status", "pause", "resume", "clear", "stop", "done"):
return await self._handle_goal_command(event)
return "Agent is running — use /goal status / pause / clear mid-run, or /stop before setting a new goal."
# Session-level toggles that are safe to run mid-agent —
# /yolo can unblock a pending approval prompt, /verbose cycles
# the tool-progress display mode for the ongoing stream.
@@ -4911,6 +4922,9 @@ class GatewayRunner:
# at the end of this function so the rewritten text is sent
# to the agent as a regular user turn.
if canonical == "goal":
return await self._handle_goal_command(event)
if canonical == "voice":
return await self._handle_voice_command(event)
@@ -5056,7 +5070,36 @@ class GatewayRunner:
_run_generation = self._begin_session_run_generation(_quick_key)
try:
return await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
_agent_result = await self._handle_message_with_agent(event, source, _quick_key, _run_generation)
# Goal continuation: after the agent returns a final response
# for this turn, check any standing /goal — the judge will
# either mark it done, pause it (budget), or enqueue a
# continuation prompt back through the adapter FIFO so the
# next turn makes more progress. Wrapped in try/except so a
# broken judge never breaks normal message handling.
try:
_final_text = ""
if isinstance(_agent_result, dict):
_final_text = str(_agent_result.get("final_response") or "")
elif isinstance(_agent_result, str):
_final_text = _agent_result
# Skip for empty responses (interrupted / errored) — the
# judge would almost always say "continue" and we'd loop
# on error. Let the user drive the next turn.
if _final_text.strip():
try:
session_entry = self.session_store.get_or_create_session(source)
except Exception:
session_entry = None
if session_entry is not None:
self._post_turn_goal_continuation(
session_entry=session_entry,
source=source,
final_response=_final_text,
)
except Exception as _goal_exc:
logger.debug("goal continuation hook failed: %s", _goal_exc)
return _agent_result
finally:
# If _run_agent replaced the sentinel with a real agent and
# then cleaned it up, this is a no-op. If we exited early
@@ -7422,6 +7465,201 @@ class GatewayRunner:
# Let the normal message handler process it
return await self._handle_message(retry_event)
# ────────────────────────────────────────────────────────────────
# /goal — persistent cross-turn goals (Ralph-style loop)
# ────────────────────────────────────────────────────────────────
def _get_goal_manager_for_event(self, event: "MessageEvent"):
"""Return a GoalManager bound to the session for this gateway event.
Returns ``(manager, session_entry)`` or ``(None, None)`` if the
goals module can't be loaded.
"""
try:
from hermes_cli.goals import GoalManager
except Exception as exc:
logger.debug("goal manager unavailable: %s", exc)
return None, None
try:
session_entry = self.session_store.get_or_create_session(event.source)
except Exception as exc:
logger.debug("goal manager: session lookup failed: %s", exc)
return None, None
sid = getattr(session_entry, "session_id", None) or ""
if not sid:
return None, None
try:
goals_cfg = (
(self.config or {}).get("goals", {})
if isinstance(self.config, dict)
else getattr(self.config, "goals", {}) or {}
)
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
max_turns = 20
return GoalManager(session_id=sid, default_max_turns=max_turns), session_entry
async def _handle_goal_command(self, event: "MessageEvent") -> str:
"""Handle /goal for gateway platforms.
Subcommands: ``/goal`` / ``/goal status`` / ``/goal pause`` /
``/goal resume`` / ``/goal clear``. Any other text becomes the
new goal.
Setting a new goal queues the goal text as the next turn so the
agent starts working on it immediately — the post-turn
continuation hook then takes over from there.
"""
args = (event.get_command_args() or "").strip()
lower = args.lower()
mgr, session_entry = self._get_goal_manager_for_event(event)
if mgr is None:
return "Goals unavailable on this session."
if not args or lower == "status":
return mgr.status_line()
if lower == "pause":
state = mgr.pause(reason="user-paused")
if state is None:
return "No goal set."
return f"⏸ Goal paused: {state.goal}"
if lower == "resume":
state = mgr.resume()
if state is None:
return "No goal to resume."
return (
f"▶ Goal resumed: {state.goal}\n"
"Send any message to continue, or wait — I'll take the next step on the next turn."
)
if lower in ("clear", "stop", "done"):
had = mgr.has_goal()
mgr.clear()
return "✓ Goal cleared." if had else "No active goal."
# Otherwise — treat the remaining text as the new goal.
try:
state = mgr.set(args)
except ValueError as exc:
return f"Invalid goal: {exc}"
# Queue the goal text as an immediate first turn so the agent
# starts making progress. The post-turn hook takes over after.
adapter = self.adapters.get(event.source.platform) if event.source else None
_quick_key = self._session_key_for_source(event.source) if event.source else None
if adapter and _quick_key:
try:
kickoff_event = MessageEvent(
text=state.goal,
message_type=MessageType.TEXT,
source=event.source,
message_id=event.message_id,
channel_prompt=event.channel_prompt,
)
self._enqueue_fifo(_quick_key, kickoff_event, adapter)
except Exception as exc:
logger.debug("goal kickoff enqueue failed: %s", exc)
return (
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"
)
def _post_turn_goal_continuation(
self,
*,
session_entry: Any,
source: Any,
final_response: str,
) -> None:
"""Run the goal judge after a gateway turn and, if still active,
enqueue a continuation prompt for the same session.
Called from ``_handle_message_with_agent`` at turn boundary, AFTER
the response has been delivered. Safe when no goal is set.
We use the adapter's pending-message / FIFO machinery so any real
user message that arrives simultaneously is handled by the same
queue and takes priority naturally.
"""
try:
from hermes_cli.goals import GoalManager
except Exception as exc:
logger.debug("goal continuation: goals module unavailable: %s", exc)
return
sid = getattr(session_entry, "session_id", None) or ""
if not sid:
return
try:
goals_cfg = (
(self.config or {}).get("goals", {})
if isinstance(self.config, dict)
else getattr(self.config, "goals", {}) or {}
)
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
max_turns = 20
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
if not mgr.is_active():
return
decision = mgr.evaluate_after_turn(final_response or "", user_initiated=True)
msg = decision.get("message") or ""
# Send the status line back to the user so they see the judge's
# verdict. Fire-and-forget via the adapter.
if msg and source is not None:
try:
adapter = self.adapters.get(source.platform)
if adapter and hasattr(adapter, "send_message"):
import asyncio as _asyncio
coro = adapter.send_message(source, msg)
if _asyncio.iscoroutine(coro):
try:
loop = _asyncio.get_event_loop()
if loop.is_running():
loop.create_task(coro)
else:
loop.run_until_complete(coro)
except RuntimeError:
# No event loop in this thread — schedule on the main one.
try:
_asyncio.run_coroutine_threadsafe(coro, self._loop)
except Exception:
pass
except Exception as exc:
logger.debug("goal continuation: status send failed: %s", exc)
if not decision.get("should_continue"):
return
prompt = decision.get("continuation_prompt") or ""
if not prompt or source is None:
return
# Enqueue via the adapter's FIFO so a user message already in
# flight preempts the continuation naturally.
try:
adapter = self.adapters.get(source.platform)
_quick_key = self._session_key_for_source(source)
if adapter and _quick_key:
cont_event = MessageEvent(
text=prompt,
message_type=MessageType.TEXT,
source=source,
message_id=None,
channel_prompt=None,
)
self._enqueue_fifo(_quick_key, cont_event, adapter)
except Exception as exc:
logger.debug("goal continuation: enqueue failed: %s", exc)
async def _handle_undo_command(self, event: MessageEvent) -> str:
"""Handle /undo command - remove the last user/assistant exchange."""
source = event.source