fix(goal): /goal <text> kicks the loop with a short pointer when the user's last message already carries the goal
`/goal <text>` queues the goal text as the next user turn to start the loop. When that text is what the user just pasted (a handoff note, a plan the agent already has), the kickoff re-sends it verbatim: in the 1,393-agent run `/goal <2,000-char handoff>` came 16 minutes after the same note was pasted as a message, and the agent spent 11 API calls / 6 min deciding it was a replay, with the note duplicated in context from then on. _goal_kick_prompt() compares the normalized goal against the LAST user message (string or block content); when contained, the kick is a one-line '[Goal set] Continue with the goal you were just given; there is no need to re-read it.' Otherwise the goal text is sent as before. Both /goal <text> and /goal draft use it. Tests (2): a just-pasted goal (plain and block content) kicks with the pointer; a new goal, an unrelated last message, and a goal pasted in an OLDER turn kick verbatim.
This commit is contained in:
@@ -2223,6 +2223,27 @@ class CLICommandsMixin:
|
||||
else:
|
||||
self._goal_set(mgr, arg)
|
||||
|
||||
# `/goal <text>` kicks the loop by queueing the goal text as the next user turn. When that text
|
||||
# is what the user JUST said (a pasted handoff note, a plan the agent already has), re-sending
|
||||
# it makes the agent spend a turn deciding it is a replay (11 API calls, 6 min, in one run) and
|
||||
# duplicates ~2k tokens of context. A short pointer starts the loop just as well.
|
||||
_GOAL_ALREADY_SEEN_KICK = "[Goal set] Continue with the goal you were just given; there is no need to re-read it."
|
||||
|
||||
def _goal_kick_prompt(self, goal: str) -> str:
|
||||
"""The goal text, or a short pointer when the last user message already carries it."""
|
||||
last_user = ""
|
||||
for msg in reversed(getattr(self, "conversation_history", None) or []):
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(str(b.get("text", "")) for b in content if isinstance(b, dict))
|
||||
last_user = str(content or "")
|
||||
break
|
||||
goal_norm = " ".join(goal.split())
|
||||
if goal_norm and goal_norm in " ".join(last_user.split()):
|
||||
return self._GOAL_ALREADY_SEEN_KICK
|
||||
return goal
|
||||
|
||||
def _kick_goal(self, prompt: str) -> bool:
|
||||
"""Queue ``prompt`` as the next turn so the loop starts without a separate message."""
|
||||
try:
|
||||
@@ -2310,7 +2331,7 @@ class CLICommandsMixin:
|
||||
_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(state.goal)
|
||||
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}")
|
||||
@@ -2343,7 +2364,7 @@ class CLICommandsMixin:
|
||||
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(state.goal)
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""`/goal <text>` kicks the loop with a short pointer when the user's last message already carries the goal.
|
||||
|
||||
In one run `/goal <2,000-char handoff note>` was issued 16 minutes after the same note had been pasted as
|
||||
a user message; the kickoff re-sent it and the agent spent 11 API calls / 6 min deciding it was a replay.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
||||
|
||||
|
||||
def _cli(history):
|
||||
cli = CLICommandsMixin.__new__(CLICommandsMixin)
|
||||
cli.conversation_history = history
|
||||
cli._pending_input = queue.Queue()
|
||||
return cli
|
||||
|
||||
|
||||
HANDOFF = "HANDOFF: resume round 3 integration.\n - merge r3-16 first\n - then run the full suite"
|
||||
|
||||
|
||||
def test_goal_that_the_user_just_pasted_kicks_with_a_pointer_not_the_text():
|
||||
cli = _cli([{"role": "user", "content": "Here is the plan.\n\n" + HANDOFF + "\n\nGo."},
|
||||
{"role": "assistant", "content": "ok"}])
|
||||
assert cli._goal_kick_prompt(HANDOFF) == CLICommandsMixin._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) == CLICommandsMixin._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"
|
||||
cli = _cli([{"role": "user", "content": "Something unrelated"}])
|
||||
assert cli._goal_kick_prompt(HANDOFF) == HANDOFF
|
||||
# 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
|
||||
Reference in New Issue
Block a user