fix(goals): make /goal work in TUI and fix gateway verdict delivery (#19209)

/goal was silently broken outside the classic CLI.

TUI: /goal was routed through the HermesCLI slash-worker subprocess,
which set the goal row in SessionDB but then called
_pending_input.put(state.goal) — the subprocess has no reader for that
queue, so the kickoff message was discarded. No post-turn judge was
wired into prompt.submit either, so even a manual kickoff would not
continue the goal loop. Intercept /goal in command.dispatch instead,
drive GoalManager directly, and return {type: send, notice, message}
so the TUI client renders the Goal-set notice and fires the kickoff.
Run the judge in _run_prompt_submit after message.complete, surface
the verdict via status.update {kind: goal}, and chain the continuation
turn after the running guard is released.

Gateway: _post_turn_goal_continuation was gated on
hasattr(adapter, 'send_message'), but adapters only expose send().
That branch was dead on every platform — users never saw
'✓ Goal achieved', 'Continuing toward goal', or budget-exhausted
messages. Replace the dead call with adapter.send(chat_id, content,
metadata) and drop a broken reference to self._loop.

Tests:
- tests/tui_gateway/test_goal_command.py — full /goal dispatch matrix
  (set / status / pause / resume / clear / stop / done / whitespace)
  plus regressions for slash.exec → 4018 and 'goal' staying in
  _PENDING_INPUT_COMMANDS.
- tests/gateway/test_goal_verdict_send.py — locks in the adapter.send
  path for done / continue / budget-exhausted and verifies the hook
  no-ops when no goal is set or the adapter lacks send().
This commit is contained in:
Teknium
2026-05-03 05:49:12 -07:00
committed by GitHub
parent 55647a5813
commit d87fd9f039
8 changed files with 593 additions and 12 deletions

View File

@@ -2822,6 +2822,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
def run():
approval_token = None
session_tokens = []
goal_followup = None # set by the post-turn goal hook below
try:
from tools.approval import (
reset_current_session_key,
@@ -2981,6 +2982,55 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
payload["rendered"] = rendered
_emit("message.complete", sid, payload)
# ── /goal continuation (Ralph-style loop) ─────────────────
# After every TUI turn, if a /goal is active, ask the judge
# whether the goal is done and — if not and we're still under
# budget — queue a continuation prompt to run after this
# thread releases session["running"]. The verdict message
# ("✓ Goal achieved" / "⏸ budget exhausted") is surfaced as
# a system line so the user sees progress regardless of
# outcome. Mirrors gateway/run._post_turn_goal_continuation.
if (
status == "complete"
and isinstance(raw, str)
and raw.strip()
):
try:
from hermes_cli.goals import GoalManager
sid_key = session.get("session_key") or ""
if sid_key:
try:
goals_cfg = (_load_cfg().get("goals") or {})
goal_max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
goal_max_turns = 20
goal_mgr = GoalManager(
session_id=sid_key,
default_max_turns=goal_max_turns,
)
if goal_mgr.is_active():
decision = goal_mgr.evaluate_after_turn(
raw, user_initiated=True,
)
verdict_msg = decision.get("message") or ""
if verdict_msg:
_emit(
"status.update",
sid,
{"kind": "goal", "text": verdict_msg},
)
if decision.get("should_continue"):
cont_prompt = decision.get("continuation_prompt") or ""
if cont_prompt:
goal_followup = cont_prompt
except Exception as _goal_exc:
print(
f"[tui_gateway] goal continuation hook failed: "
f"{type(_goal_exc).__name__}: {_goal_exc}",
file=sys.stderr,
)
# Apply pending_title now that the DB row exists.
_pending = session.get("pending_title")
if _pending and status == "complete":
@@ -3061,6 +3111,31 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
with session["history_lock"]:
session["running"] = False
# Chain a goal-continuation turn if the judge said so. We do
# this AFTER the finally releases session["running"], so the
# nested _run_prompt_submit doesn't deadlock on the busy
# guard. A real user prompt that races us wins because
# prompt.submit sets running=True under the history_lock and
# we check that guard before re-firing.
if goal_followup:
with session["history_lock"]:
if session.get("running"):
# User already sent something — their turn wins,
# the judge will re-run on the next turn anyway.
return
session["running"] = True
try:
_emit("message.start", sid)
_run_prompt_submit(rid, sid, session, goal_followup)
except Exception as _cont_exc:
print(
f"[tui_gateway] goal continuation dispatch failed: "
f"{type(_cont_exc).__name__}: {_cont_exc}",
file=sys.stderr,
)
with session["history_lock"]:
session["running"] = False
threading.Thread(target=run, daemon=True).start()
@@ -3928,6 +4003,7 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
"q",
"steer",
"plan",
"goal",
}
)
@@ -4240,6 +4316,77 @@ def _(rid, params: dict) -> dict:
# Fallback: no active run, treat as next-turn message
return _ok(rid, {"type": "send", "message": arg})
if name == "goal":
if not session:
return _err(rid, 4001, "no active session")
try:
from hermes_cli.goals import GoalManager
except Exception as exc:
return _err(rid, 5030, f"goals unavailable: {exc}")
sid_key = session.get("session_key") or ""
if not sid_key:
return _err(rid, 4001, "no session key")
try:
goals_cfg = (_load_cfg().get("goals") or {})
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
max_turns = 20
mgr = GoalManager(session_id=sid_key, default_max_turns=max_turns)
lower = arg.strip().lower()
if not arg.strip() or lower == "status":
return _ok(rid, {"type": "exec", "output": mgr.status_line()})
if lower == "pause":
state = mgr.pause(reason="user-paused")
out = "No goal set." if state is None else f"⏸ Goal paused: {state.goal}"
return _ok(rid, {"type": "exec", "output": out})
if lower == "resume":
state = mgr.resume()
if state is None:
return _ok(rid, {"type": "exec", "output": "No goal to resume."})
return _ok(
rid,
{
"type": "exec",
"output": (
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 _ok(
rid,
{
"type": "exec",
"output": "✓ Goal cleared." if had else "No active goal.",
},
)
# Otherwise — treat the remaining text as the new goal.
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"
)
# Send the goal text as the kickoff prompt. The TUI client sees
# {type: send, notice, message} → renders `notice` as a sys line,
# then submits `message` as a user turn. The post-turn judge
# wired in _run_prompt_submit takes over from there.
return _ok(
rid,
{"type": "send", "notice": notice, "message": state.goal},
)
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")