Skip to content

Commit 98fba3e

Browse files
author
sammysays93
committed
feat: integrate /goal persistent cross-turn goals (Ralph loop) - PRs NousResearch#18262 + NousResearch#18275
1 parent 99ba285 commit 98fba3e

8 files changed

Lines changed: 1530 additions & 3 deletions

File tree

cli.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5515,6 +5515,8 @@ def process_command(self, command: str) -> bool:
55155515
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
55165516
else:
55175517
_cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
5518+
elif canonical == "goal":
5519+
self._handle_goal_command(cmd_original)
55185520
elif canonical == "skin":
55195521
self._handle_skin_command(cmd_original)
55205522
elif canonical == "voice":
@@ -7031,6 +7033,166 @@ def _voice_speak_response(self, text: str):
70317033
finally:
70327034
self._voice_tts_done.set()
70337035

7036+
# ────────────────────────────────────────────────────────────────
7037+
# /goal — persistent cross-turn goals (Ralph-style loop)
7038+
# ────────────────────────────────────────────────────────────────
7039+
def _get_goal_manager(self):
7040+
"""Return the GoalManager bound to the current session_id.
7041+
7042+
Cached on ``self._goal_manager`` and rebound lazily when
7043+
``session_id`` changes (e.g. after /new or a compression-driven
7044+
session split).
7045+
"""
7046+
try:
7047+
from hermes_cli.goals import GoalManager
7048+
from hermes_cli.config import load_config
7049+
except Exception as exc:
7050+
logging.debug("goal manager unavailable: %s", exc)
7051+
return None
7052+
7053+
sid = getattr(self, "session_id", None) or ""
7054+
if not sid:
7055+
return None
7056+
7057+
existing = getattr(self, "_goal_manager", None)
7058+
if existing is not None and getattr(existing, "session_id", None) == sid:
7059+
return existing
7060+
7061+
try:
7062+
cfg = load_config() or {}
7063+
goals_cfg = cfg.get("goals") or {}
7064+
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
7065+
except Exception:
7066+
max_turns = 20
7067+
7068+
mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
7069+
self._goal_manager = mgr
7070+
return mgr
7071+
7072+
def _handle_goal_command(self, cmd: str) -> None:
7073+
"""Dispatch /goal subcommands: set / status / pause / resume / clear."""
7074+
parts = (cmd or "").strip().split(None, 1)
7075+
arg = parts[1].strip() if len(parts) > 1 else ""
7076+
7077+
mgr = self._get_goal_manager()
7078+
if mgr is None:
7079+
_cprint(f" {_DIM}Goals unavailable (no active session).{_RST}")
7080+
return
7081+
7082+
lower = arg.lower()
7083+
7084+
# Bare /goal or /goal status → show current state
7085+
if not arg or lower == "status":
7086+
_cprint(f" {mgr.status_line()}")
7087+
return
7088+
7089+
if lower == "pause":
7090+
state = mgr.pause(reason="user-paused")
7091+
if state is None:
7092+
_cprint(f" {_DIM}No goal set.{_RST}")
7093+
else:
7094+
_cprint(f" ⏸ Goal paused: {state.goal}")
7095+
return
7096+
7097+
if lower == "resume":
7098+
state = mgr.resume()
7099+
if state is None:
7100+
_cprint(f" {_DIM}No goal to resume.{_RST}")
7101+
else:
7102+
_cprint(f" ▶ Goal resumed: {state.goal}")
7103+
_cprint(
7104+
f" {_DIM}Send any message (or press Enter on an empty prompt "
7105+
f"is a no-op; type 'continue' to kick it off).{_RST}"
7106+
)
7107+
return
7108+
7109+
if lower in ("clear", "stop", "done"):
7110+
had = mgr.has_goal()
7111+
mgr.clear()
7112+
if had:
7113+
_cprint(" ✓ Goal cleared.")
7114+
else:
7115+
_cprint(f" {_DIM}No active goal.{_RST}")
7116+
return
7117+
7118+
# Otherwise treat the arg as the goal text.
7119+
try:
7120+
state = mgr.set(arg)
7121+
except ValueError as exc:
7122+
_cprint(f" Invalid goal: {exc}")
7123+
return
7124+
7125+
_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
7126+
_cprint(
7127+
f" {_DIM}After each turn, a judge model will check if the goal is done. "
7128+
f"Hermes keeps working until it is, you pause/clear it, or the budget is "
7129+
f"exhausted. Use /goal status, /goal pause, /goal resume, /goal clear.{_RST}"
7130+
)
7131+
# Kick the loop off immediately so the user doesn't have to send a
7132+
# separate message after setting the goal.
7133+
try:
7134+
self._pending_input.put(state.goal)
7135+
except Exception:
7136+
pass
7137+
7138+
def _maybe_continue_goal_after_turn(self) -> None:
7139+
"""Hook run after every CLI turn. Judges + maybe re-queues.
7140+
7141+
Safe to call when no goal is set — returns quickly.
7142+
7143+
Preemption is automatic: if a real user message is already in
7144+
``_pending_input`` we skip judging (the user's new input takes
7145+
priority and we'll re-judge after that turn). If judge says done,
7146+
mark it done and tell the user. If judge says continue and we're
7147+
under budget, push the continuation prompt onto the queue.
7148+
"""
7149+
mgr = self._get_goal_manager()
7150+
if mgr is None or not mgr.is_active():
7151+
return
7152+
7153+
# If a real user message is already queued, don't inject a
7154+
# continuation prompt on top — let the user's turn go first.
7155+
try:
7156+
if getattr(self, "_pending_input", None) is not None \
7157+
and not self._pending_input.empty():
7158+
return
7159+
except Exception:
7160+
pass
7161+
7162+
# Extract the agent's final response for this turn.
7163+
last_response = ""
7164+
try:
7165+
hist = self.conversation_history or []
7166+
for msg in reversed(hist):
7167+
if msg.get("role") == "assistant":
7168+
content = msg.get("content", "")
7169+
if isinstance(content, list):
7170+
# Multimodal content — flatten text parts.
7171+
parts = [
7172+
p.get("text", "")
7173+
for p in content
7174+
if isinstance(p, dict) and p.get("type") in ("text", "output_text")
7175+
]
7176+
last_response = "\n".join(t for t in parts if t)
7177+
else:
7178+
last_response = str(content or "")
7179+
break
7180+
except Exception:
7181+
last_response = ""
7182+
7183+
decision = mgr.evaluate_after_turn(last_response, user_initiated=True)
7184+
msg = decision.get("message") or ""
7185+
if msg:
7186+
_cprint(f" {msg}")
7187+
7188+
if decision.get("should_continue"):
7189+
prompt = decision.get("continuation_prompt")
7190+
if prompt:
7191+
try:
7192+
self._pending_input.put(prompt)
7193+
except Exception as exc:
7194+
logging.debug("goal continuation enqueue failed: %s", exc)
7195+
70347196
def _handle_voice_command(self, command: str):
70357197
"""Handle /voice [on|off|tts|status] command."""
70367198
parts = command.strip().split(maxsplit=1)
@@ -9611,6 +9773,17 @@ def _expand_ref(m):
96119773

96129774
app.invalidate() # Refresh status line
96139775

9776+
# Goal continuation: if a standing goal is active, ask
9777+
# the judge whether the turn satisfied it. If not, and
9778+
# there's no real user message already queued, push the
9779+
# continuation prompt back into _pending_input so the
9780+
# next loop iteration picks it up naturally (and any
9781+
# user input that arrives in between still preempts).
9782+
try:
9783+
self._maybe_continue_goal_after_turn()
9784+
except Exception as _goal_exc:
9785+
logging.debug("goal continuation hook failed: %s", _goal_exc)
9786+
96149787
# Continuous voice: auto-restart recording after agent responds.
96159788
# Dispatch to a daemon thread so play_beep (sd.wait) and
96169789
# AudioRecorder.start (lock acquire) never block process_loop —

0 commit comments

Comments
 (0)