From 11205eecffb360a13e515a9e6293c0fb268316b1 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 20 May 2026 20:19:37 +0700 Subject: [PATCH 1/4] fix(goal): honor agent self-attestation to stop the loop (#29090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reproducer is ``/goal lsdjflasjdf;ljasdlfja;sldjfalsdjf``: a goal with no verifiable success criterion. The agent's first reply almost always says "I don't understand this — please clarify" (in prose), which a *strict* judge per the system prompt is supposed to treat as DONE-with-reason-blocked. Weak judge models routinely fail that rule and hedge with ``continue``, so the loop spam-fires continuation prompts at every turn until the 20-turn budget runs out — the exact "keeps triggering until the maximum limit is reached" symptom on Windows reported here (and the broader weak-judge class tracked in #27585). Nothing about the failure mode is Windows-specific; the reporter just happened to hit it there first. The fix is a deterministic, model-independent stop path: the continuation prompt now teaches the agent two terminal sentinels — <> <> — and ``evaluate_after_turn`` checks the assistant's last response for one *before* calling the judge. When the sentinel is on the final non-blank line, the loop halts immediately with the agent's own reason surfaced to the user (``✓ Goal achieved: …`` or ``✓ Goal stopped (agent blocked): …``). Anchoring on the last line keeps prompt-echoing models from false-positive-stopping when they quote the instruction back to themselves mid-reasoning. Three small pieces wired together: * ``GOAL_DONE_SENTINEL`` / ``GOAL_BLOCKED_SENTINEL`` constants + ``_STOP_SENTINEL_RE`` (case-insensitive, optional reason group so bare ``<>`` still trips with a fallback reason). * ``_detect_goal_stop_sentinel(response)`` returns ``(kind, reason)`` only when the sentinel is the last non-blank line. * ``evaluate_after_turn`` short-circuits to a "done" verdict on detection, resets the consecutive-parse-failures counter (sentinel emission means we got a real reply, so stale judge-parse failures must not auto-pause the next turn), and persists status="done". The CONTINUATION_PROMPT_TEMPLATE and CONTINUATION_PROMPT_WITH_ SUBGOALS_TEMPLATE both share a single ``_STOP_INSTRUCTION_LINE`` so the subgoals path stays in lock-step with the plain path. The judge fail-open semantics, parse-failure auto-pause, turn-budget backstop, and ``/goal pause`` / ``resume`` / ``clear`` controls are all untouched — sentinel handling is purely additive. --- hermes_cli/goals.py | 112 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 7 deletions(-) diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 62ee00547c16f..0c8110a804864 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -67,12 +67,35 @@ DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3 +# Agent self-attestation sentinels. Emitted by the model on its own +# line at the END of a response to break the goal loop deterministically +# — without consulting the judge. Closes #29090: a gibberish or +# otherwise unverifiable goal (``/goal lsdjflasjdf;ljasdlfja;sldjfalsdjf``) +# used to spam continuations until the turn budget ran out because the +# judge model couldn't decide "done" on a goal it didn't understand. +# Now the agent itself signals "I'm done / blocked" and the loop stops +# immediately. +GOAL_DONE_SENTINEL = "<>" +GOAL_BLOCKED_SENTINEL = "<>" + +_STOP_INSTRUCTION_LINE = ( + "When you have nothing more to do for this goal, end your final " + "message with a line that is EXACTLY one of:\n" + " <>\n" + " <>\n" + "The sentinel must be the LAST non-blank line in your response. " + "Emitting one stops the loop without waiting for the judge — use " + "DONE when the goal is complete or trivially unachievable (typos, " + "gibberish, contradictions), BLOCKED when you genuinely need user " + "input to make progress." +) + + CONTINUATION_PROMPT_TEMPLATE = ( "[Continuing toward your standing goal]\n" "Goal: {goal}\n\n" - "Continue working toward this goal. Take the next concrete step. " - "If you believe the goal is complete, state so explicitly and stop. " - "If you are blocked and need input from the user, say so clearly and stop." + "Continue working toward this goal. Take the next concrete step.\n\n" + f"{_STOP_INSTRUCTION_LINE}" ) # Used when the user has added one or more /subgoal criteria. Surfaced @@ -84,10 +107,8 @@ "Additional criteria the user added mid-loop:\n" "{subgoals_block}\n\n" "Continue working toward the goal AND all additional criteria. Take " - "the next concrete step. If you believe the goal and every " - "additional criterion are complete, state so explicitly and stop. " - "If you are blocked and need input from the user, say so clearly " - "and stop." + "the next concrete step.\n\n" + f"{_STOP_INSTRUCTION_LINE}" ) @@ -316,6 +337,54 @@ def _goal_judge_max_tokens() -> int: return DEFAULT_JUDGE_MAX_TOKENS +# Regex anchored on the sentinel. Reason group is optional so a bare +# ``<>`` still trips, with a default reason supplied +# by the caller. Case-insensitive so weak models that lowercase +# everything still match. +_STOP_SENTINEL_RE = re.compile( + r"<<\s*HERMES_GOAL_(DONE|BLOCKED)\s*(?::\s*([^>]+?))?\s*>>", + re.IGNORECASE, +) + + +def _detect_goal_stop_sentinel(response: str) -> Optional[Tuple[str, str]]: + """Return ``(kind, reason)`` if the agent self-attested completion. + + Only fires when the sentinel appears on the **last non-blank line** + of the response. The continuation prompt itself contains the + sentinel as an instruction; many models echo their instructions + mid-response. Anchoring on the last line keeps that echo from + false-positive-stopping the loop — the agent has to make the + sentinel the final thing they say, matching the prompt contract + "the sentinel must be the LAST non-blank line in your response". + + ``kind`` is ``"done"`` or ``"blocked"``. Both halt the loop; + ``"blocked"`` exists so the surfaced message tells the user the + agent wants their input rather than reporting success. + """ + if not response: + return None + last_line = "" + for line in reversed(response.splitlines()): + if line.strip(): + last_line = line + break + if not last_line: + return None + match = _STOP_SENTINEL_RE.search(last_line) + if not match: + return None + kind = match.group(1).lower() + reason = (match.group(2) or "").strip() + if not reason: + reason = ( + "agent attested goal completion" + if kind == "done" + else "agent reported blocked — needs user input" + ) + return kind, reason + + def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: """Parse the judge's reply. Fail-open to ``(False, "", parse_failed)``. @@ -646,6 +715,35 @@ def evaluate_after_turn( state.turns_used += 1 state.last_turn_at = time.time() + # Honor explicit agent self-attestation BEFORE the judge. Weak + # judge models can't reliably mark a gibberish/unverifiable goal + # as "done" — they hedge with "continue" and spam the budget + # (#29090, #27585). The continuation prompt now teaches the + # agent two terminal sentinels (``<>`` / + # ``<>``); if the agent emits one as the + # last non-blank line of its reply, trust it and stop without + # an extra judge round-trip. + sentinel = _detect_goal_stop_sentinel(last_response) + if sentinel is not None: + kind, sentinel_reason = sentinel + state.status = "done" + state.last_verdict = "done" + state.last_reason = sentinel_reason + # Sentinel emission means we got a real reply; reset the + # consecutive-parse-failures counter so a future judge call + # doesn't auto-pause on stale state. + state.consecutive_parse_failures = 0 + save_goal(self.session_id, state) + label = "achieved" if kind == "done" else "stopped (agent blocked)" + return { + "status": "done", + "should_continue": False, + "continuation_prompt": None, + "verdict": "done", + "reason": sentinel_reason, + "message": f"✓ Goal {label}: {sentinel_reason}", + } + verdict, reason, parse_failed = judge_goal( state.goal, last_response, subgoals=state.subgoals or None ) From 1a7dee6978e9671b01980fdf8ff5223d5bd3d1b6 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 20 May 2026 20:23:29 +0700 Subject: [PATCH 2/4] fix(goal): require sentinel to be the full final line, not a substring (#29090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tightens ``_detect_goal_stop_sentinel`` from ``.search(last_line)`` to ``.fullmatch(stripped)``. Without this guard, a one-line response that quotes the sentinel inline while continuing to work — e.g. "Per the instructions, I should emit <> when I finish. Right now I'm still working — running tests next." — would short-circuit the loop even though the agent explicitly said they're still working. The prompt contract is "the sentinel must be the LAST non-blank line in your response", and ``fullmatch`` is what actually enforces that contract: the entire final non-blank line, after stripping surrounding whitespace, must be the sentinel and nothing else. Multi-line responses ending on a clean sentinel line still trip — that's the supported shape — and bare ``<>`` / ``<>`` (no reason group) still match because the regex makes the reason group optional. --- hermes_cli/goals.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 0c8110a804864..d8fb65fa0a607 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -350,12 +350,14 @@ def _goal_judge_max_tokens() -> int: def _detect_goal_stop_sentinel(response: str) -> Optional[Tuple[str, str]]: """Return ``(kind, reason)`` if the agent self-attested completion. - Only fires when the sentinel appears on the **last non-blank line** - of the response. The continuation prompt itself contains the - sentinel as an instruction; many models echo their instructions - mid-response. Anchoring on the last line keeps that echo from - false-positive-stopping the loop — the agent has to make the - sentinel the final thing they say, matching the prompt contract + The sentinel must be the **entire** final non-blank line of the + response (only the sentinel itself + optional surrounding + whitespace allowed on that line). The continuation prompt + contains the sentinel as an instruction, and models routinely + echo their instructions mid-reasoning — including inline, on the + same line as their normal prose. Requiring the final line to be + the sentinel and nothing else keeps those echoes from false- + positive-stopping the loop and matches the prompt contract "the sentinel must be the LAST non-blank line in your response". ``kind`` is ``"done"`` or ``"blocked"``. Both halt the loop; @@ -371,7 +373,8 @@ def _detect_goal_stop_sentinel(response: str) -> Optional[Tuple[str, str]]: break if not last_line: return None - match = _STOP_SENTINEL_RE.search(last_line) + stripped = last_line.strip() + match = _STOP_SENTINEL_RE.fullmatch(stripped) if not match: return None kind = match.group(1).lower() From 9a87119c64eea4156ce56cc5c45e5d104172efae Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 20 May 2026 20:23:45 +0700 Subject: [PATCH 3/4] test(goal): regression coverage for the #29090 sentinel stop path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new test classes plus an end-to-end repro of the exact scenario from the bug report. * ``TestStopSentinelDetection`` — unit-level coverage of ``_detect_goal_stop_sentinel``: - happy paths for DONE and BLOCKED, with and without a reason suffix, mixed case, extra whitespace, trailing newlines; - rejection of empty/``None`` input, of sentinel-shaped strings in prose context, and (the prompt-echo guard) of sentinels embedded inline with surrounding text on the final line; - ``next_continuation_prompt`` carries the sentinel teaching in both the plain and ``/subgoal`` paths so the agent actually knows the contract on every turn. * ``TestEvaluateAfterTurnSentinel`` — end-to-end behaviour through ``GoalManager.evaluate_after_turn``: - DONE sentinel short-circuits the judge and lands ``status="done"``; - BLOCKED sentinel does the same with the user-visible message flagging "blocked" so the operator knows the agent wants input instead of misreading it as success; - the sentinel branch resets ``consecutive_parse_failures`` so a flaky judge that built up failures before the sentinel landed doesn't auto-pause the next ``/goal resume``; - non-sentinel responses still call the judge (regression guard for the existing path); - inline sentinels on a multi-text final line are NOT treated as stops — the judge still runs (the prompt-echo guard, end-to-end); - sentinels work alongside ``/subgoal`` criteria; - sentinels on an inactive goal are inert (the early-return on ``status != 'active'`` wins). * ``TestRepro29090GibberishGoal`` — full two-turn reproduction of the reporter's exact scenario (``/goal lsdjflasjdf;ljasdlfja;sldjfalsdjf``): turn 1 sees a weak judge return "continue" so the continuation prompt fires; turn 2 sees the agent emit ``<>`` and the loop halts at 2 turns used instead of 20. Asserts the continuation prompt carries the sentinel teaching (otherwise the agent couldn't emit it) and that the judge is never called on turn 2. --- tests/hermes_cli/test_goals.py | 280 +++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index 9d8c3f48fe1db..9ea9180749a8e 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -738,3 +738,283 @@ def test_status_line_with_subgoals(self, hermes_home): mgr.add_subgoal("b") line = mgr.status_line() assert "2 subgoals" in line + + +# ────────────────────────────────────────────────────────────────────── +# Agent self-attestation stop sentinels (#29090) +# ────────────────────────────────────────────────────────────────────── + + +class TestStopSentinelDetection: + """Unit-level coverage for ``_detect_goal_stop_sentinel``. + + The sentinel only fires when it's the LAST non-blank line of the + response — anything else would false-positive on agents that echo + the continuation prompt's instruction text back in their reasoning. + """ + + @pytest.mark.parametrize("response,kind,reason_fragment", [ + ("Work done.\n<>", + "done", "shipped feature x"), + ("Final reply.\n<>", + "blocked", "need api key"), + # Bare sentinel without reason — fallback reason supplied. + ("All good.\n<>", "done", "agent"), + ("Cannot proceed.\n<>", "blocked", "blocked"), + # Mixed casing — regex is case-insensitive. + ("ok.\n<>", "done", "lowercase"), + # Extra whitespace around the reason gets stripped. + ("ok.\n<>", + "done", "spaced reason"), + # Whitespace after sentinel still treated as last line. + ("ok.\n<>\n\n \n", + "done", "trailing newlines"), + ]) + def test_detection_happy_paths(self, response, kind, reason_fragment): + from hermes_cli.goals import _detect_goal_stop_sentinel + + result = _detect_goal_stop_sentinel(response) + assert result is not None + out_kind, out_reason = result + assert out_kind == kind + assert reason_fragment.lower() in out_reason.lower() + + @pytest.mark.parametrize("response", [ + "", + None, + "Just a normal response with no sentinel.", + # Bare prose mentioning the sentinel string but not as a marker. + "Per the instructions, I would emit HERMES_GOAL_DONE here.", + # Sentinel in the MIDDLE of the response — agent kept talking, + # so they didn't actually want to stop. This is the prompt-echo + # false-positive guard. + ("I see the instructions: <>\n\n" + "Now, here is my actual work for this turn: step 1, step 2, " + "step 3.\nI will continue next turn."), + # Same shape with BLOCKED. + ("<>\n\n" + "Actually I'm not blocked — pushing on."), + ]) + def test_detection_rejects_non_terminal_or_absent(self, response): + from hermes_cli.goals import _detect_goal_stop_sentinel + assert _detect_goal_stop_sentinel(response) is None + + def test_continuation_prompt_teaches_both_sentinels(self, hermes_home): + """The continuation prompt the agent receives every loop turn + must teach the sentinel contract — otherwise the agent can't + emit it and the fix is dead on arrival.""" + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sentinel-teach") + mgr.set("some long goal") + prompt = mgr.next_continuation_prompt() + assert prompt is not None + assert "<>" + ) + judge_mock.assert_not_called(), ( + "judge must NOT run when the agent emits a terminal sentinel " + "— that's the whole point of #29090" + ) + assert decision["verdict"] == "done" + assert decision["should_continue"] is False + assert decision["continuation_prompt"] is None + assert "unverifiable" in decision["reason"] + assert mgr.state.status == "done" + + def test_blocked_sentinel_short_circuits_judge(self, hermes_home): + from hermes_cli import goals + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-blocked") + mgr.set("need-user-input goal") + + with patch.object(goals, "judge_goal") as judge_mock: + decision = mgr.evaluate_after_turn( + "I need credentials to proceed.\n" + "<>" + ) + judge_mock.assert_not_called() + assert decision["verdict"] == "done" + assert decision["should_continue"] is False + assert "blocked" in decision["message"].lower() + assert "AWS_PROFILE" in decision["reason"] + assert mgr.state.status == "done" + + def test_sentinel_resets_parse_failure_counter(self, hermes_home): + """If a flaky judge had built up consecutive parse failures, the + sentinel landing must clear the counter so the *next* goal + (after /goal resume + a real prompt) doesn't immediately + auto-pause on stale state.""" + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-reset") + mgr.set("g") + mgr.state.consecutive_parse_failures = 2 # one short of the cap + mgr.evaluate_after_turn("ok\n<>") + assert mgr.state.consecutive_parse_failures == 0 + + def test_non_sentinel_response_still_calls_judge(self, hermes_home): + """Regression guard for the existing judge path — make sure the + new sentinel branch only triggers when the sentinel is present.""" + from hermes_cli import goals + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-passthrough") + mgr.set("a goal") + + with patch.object( + goals, "judge_goal", return_value=("continue", "more", False) + ) as judge_mock: + decision = mgr.evaluate_after_turn("Plain prose with no sentinel.") + judge_mock.assert_called_once() + assert decision["verdict"] == "continue" + assert decision["should_continue"] is True + + def test_sentinel_inside_response_but_not_last_line_calls_judge( + self, hermes_home + ): + """The prompt-echo guard, end-to-end: an agent that quotes the + sentinel mid-response but keeps talking afterward must NOT + short-circuit — the judge still has the final say.""" + from hermes_cli import goals + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-echo") + mgr.set("a goal") + + echo_response = ( + "Per the instructions, I should emit " + "<> when I finish. " + "Right now I'm still working — running tests next." + ) + with patch.object( + goals, "judge_goal", return_value=("continue", "more", False) + ) as judge_mock: + decision = mgr.evaluate_after_turn(echo_response) + judge_mock.assert_called_once() + assert decision["verdict"] == "continue" + + def test_sentinel_with_subgoals_active_still_short_circuits( + self, hermes_home + ): + """If the user added /subgoal criteria mid-loop, the sentinel + path still honors the agent's stop — the agent has full + context including subgoals in the continuation prompt and is + in the best position to attest completion.""" + from hermes_cli import goals + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-sub") + mgr.set("base goal") + mgr.add_subgoal("extra criterion") + + with patch.object(goals, "judge_goal") as judge_mock: + decision = mgr.evaluate_after_turn( + "Wrapping up.\n<>" + ) + judge_mock.assert_not_called() + assert decision["verdict"] == "done" + assert mgr.state.status == "done" + + def test_sentinel_on_inactive_goal_is_inert(self, hermes_home): + """If no goal is active, sentinel detection must not invent one + — the early return on ``state.status != 'active'`` runs first.""" + from hermes_cli.goals import GoalManager + + mgr = GoalManager(session_id="sent-inactive") + # No goal set — state is None. + decision = mgr.evaluate_after_turn( + "<>" + ) + assert decision["verdict"] == "inactive" + assert mgr.state is None + + +class TestRepro29090GibberishGoal: + """End-to-end reproduction of the exact reporter scenario: + ``/goal lsdjflasjdf;ljasdlfja;sldjfalsdjf`` (gibberish). + + Pre-fix: weak judges return ``continue`` for every turn and the + loop spam-fires continuation prompts until the 20-turn budget + runs out. Post-fix: turn 2's continuation prompt teaches the + sentinel, the agent emits ``<>`` on its + next reply, and the loop halts in one extra turn instead of + nineteen. This test models that two-turn shape with a fake + weak judge and a sentinel-emitting agent.""" + + def test_full_loop_halts_within_two_turns_post_fix(self, hermes_home): + from hermes_cli import goals + from hermes_cli.goals import GoalManager + + mgr = GoalManager( + session_id="repro-29090", + default_max_turns=20, # the reporter's "maximum limit" + ) + mgr.set("lsdjflasjdf;ljasdlfja;sldjfalsdjf") + + # Turn 1: agent replies in normal prose, weak judge keeps + # hedging on "continue" — pre-fix this is where the spam + # started. Post-fix the judge still runs (no sentinel yet) and + # queues turn 2. + with patch.object( + goals, "judge_goal", return_value=("continue", "unclear", False) + ): + d1 = mgr.evaluate_after_turn( + "I don't understand the goal text. Could you clarify?" + ) + assert d1["should_continue"] is True + assert mgr.state.turns_used == 1 + # The continuation prompt MUST teach the sentinel so turn 2 can + # actually emit it. + assert "<>" + ) + judge_mock.assert_not_called() + assert d2["should_continue"] is False + assert d2["verdict"] == "done" + assert mgr.state.status == "done" + assert mgr.state.turns_used == 2 + # Burned 2 of 20 turns, not all 20 — the regression is sealed. + assert mgr.state.turns_used < mgr.state.max_turns From 7cb1d32162400a82c7aed93323f91451134a0552 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 20 May 2026 20:24:29 +0700 Subject: [PATCH 4/4] docs(goal): document the agent self-attestation sentinels (#29090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Agent self-attestation (stop sentinels)" subsection to ``goals.md`` covering ``<>`` and ``<>``: why they exist (gibberish or otherwise unverifiable goals where weak judges hedge with ``continue`` and burn the turn budget), how to use them (must be the entire final non-blank line of the reply), and what the user sees on the surface (``✓ Goal achieved`` vs ``✓ Goal stopped (agent blocked)``). Updates the "When the judge gets it wrong" section to point at the sentinel as the first line of defense against false-negative judges, with the turn budget still backing it up. --- website/docs/user-guide/features/goals.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/features/goals.md b/website/docs/user-guide/features/goals.md index f97502f3bd508..d2259ebac5683 100644 --- a/website/docs/user-guide/features/goals.md +++ b/website/docs/user-guide/features/goals.md @@ -63,6 +63,19 @@ The judge is deliberately conservative: it marks a goal `done` only when the res If the judge errors (network blip, malformed response, unavailable aux client), Hermes treats the verdict as `continue` — a broken judge never wedges progress. The **turn budget** is the real backstop. +### Agent self-attestation (stop sentinels) + +Some goals are unverifiable from the outside — the user typed gibberish (`/goal lsdjflasjdf;ljasdlfja;sldjfalsdjf`), the goal is internally contradictory, or it genuinely needs more user input. Weak judge models can't always recognise these cases and will hedge with `continue` until the turn budget runs out (issue [#29090](https://github.com/NousResearch/hermes-agent/issues/29090)). To make the stop deterministic, the continuation prompt teaches the agent two terminal sentinels: + +``` +<> +<> +``` + +If the agent ends its reply with one of these as the **entire** final non-blank line, Hermes halts the loop immediately without consulting the judge. `DONE` surfaces as `✓ Goal achieved: `; `BLOCKED` surfaces as `✓ Goal stopped (agent blocked): ` so you know the agent wants your input rather than reporting success. + +The detector is anchored on the final line (and requires it to be _only_ the sentinel after stripping whitespace), so models that quote the instruction back to themselves mid-reasoning don't false-positive-stop the loop. + ### Turn budget Default is 20 continuation turns (`goals.max_turns` in `config.yaml`). When the budget is hit, Hermes auto-pauses and tells you exactly how to proceed: @@ -154,7 +167,7 @@ Four turns, one `/goal` invocation, zero "keep going" prompts from you. No judge is perfect. Two failure modes to watch for: -**False negative — judge says continue when the goal is actually done.** The turn budget catches this. You'll see `⏸ Goal paused` and can `/goal clear` or just send a new message. +**False negative — judge says continue when the goal is actually done.** The agent self-attestation sentinel ([above](#agent-self-attestation-stop-sentinels)) catches most of these — the agent emits `<>` on its final line and the loop stops without the judge. Anything that slips past that is caught by the turn budget: you'll see `⏸ Goal paused` and can `/goal clear` or just send a new message. **False positive — judge says done when work remains.** You'll see `✓ Goal achieved` but you know better. Send a follow-up message to continue, or re-set the goal more precisely: `/goal `. The judge's system prompt is deliberately conservative to make false positives rarer than false negatives.