Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9524,7 +9524,7 @@ def _get_goal_manager(self):
session split).
"""
try:
from hermes_cli.goals import GoalManager
from hermes_cli.goals import GoalManager, resolve_goal_max_turns
from hermes_cli.config import load_config
except Exception as exc:
logging.debug("goal manager unavailable: %s", exc)
Expand All @@ -9541,8 +9541,12 @@ def _get_goal_manager(self):
try:
cfg = load_config() or {}
goals_cfg = cfg.get("goals") or {}
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
# resolve_goal_max_turns maps the unbounded sentinel (0/negative/
# string) to None; a finite positive int is unchanged.
max_turns = resolve_goal_max_turns(goals_cfg.get("max_turns"))
except Exception:
# On any config error fall back to a FINITE default — a broken
# config must never silently grant an unbounded loop.
max_turns = 20

mgr = GoalManager(session_id=sid, default_max_turns=max_turns)
Expand Down
12 changes: 10 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -14175,14 +14175,20 @@ async def _handle_blueprint_command(self, event: MessageEvent):
# ────────────────────────────────────────────────────────────────
# /goal — persistent cross-turn goals (Ralph-style loop)
# ────────────────────────────────────────────────────────────────
def _goal_max_turns_from_config(self) -> int:
def _goal_max_turns_from_config(self) -> "Optional[int]":
"""Resolve the configured /goal turn budget for gateway sessions.

GatewayRunner.config is a GatewayConfig dataclass, not the full
user config mapping. Top-level config blocks such as ``goals`` are
therefore only available through hermes_cli.config.load_config().

Returns a positive int for a finite budget, or ``None`` for an
unbounded budget (``goals.max_turns`` of ``0``/negative/an
unbounded-string, via ``resolve_goal_max_turns``).
"""
try:
from hermes_cli.goals import resolve_goal_max_turns

goals_cfg = (
(self.config or {}).get("goals", {})
if isinstance(self.config, dict)
Expand All @@ -14192,8 +14198,10 @@ def _goal_max_turns_from_config(self) -> int:
from hermes_cli.config import load_config

goals_cfg = (load_config() or {}).get("goals") or {}
return int(goals_cfg.get("max_turns", 20) or 20)
return resolve_goal_max_turns(goals_cfg.get("max_turns"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why resolve at command-handling time, not construction: GatewayRunner is long-lived, so resolving per /goal command means an operator's config.yaml edit takes effect on the next goal with no gateway restart. The except below falls to finite 20 on any error — malformed config must fail toward the bounded attractor, never silently grant an unbounded loop.

(Annotation note: this head still says -> int though the resolver can return None; corrected to -> Optional[int] in the prepared follow-up commit.)

except Exception:
# Finite fallback: a config error must not silently grant an
# unbounded loop.
return 20

async def _get_goal_manager_for_event(self, event: "MessageEvent"):
Expand Down
5 changes: 4 additions & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2486,7 +2486,10 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
except Exception as exc:
logger.debug("goal kickoff enqueue failed: %s", exc)

base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
if state.max_turns is None:
base = t("gateway.goal.set_unbounded", goal=state.goal)
else:
base = t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
if state.has_contract():
return f"{base}\nCompletion contract:\n{state.contract.render_block()}"
if lower.startswith("draft"):
Expand Down
8 changes: 4 additions & 4 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2143,7 +2143,7 @@ def _handle_goal_command(self, cmd: str) -> None:
# lines (verify:, constraints:, boundaries:, stop when:) are parsed
# into a completion contract; the remaining prose is the headline.
# A plain free-form goal with no such lines behaves exactly as before.
from hermes_cli.goals import parse_contract
from hermes_cli.goals import parse_contract, goal_budget_label

headline, contract = parse_contract(arg)
goal_text = headline or arg
Expand All @@ -2153,7 +2153,7 @@ def _handle_goal_command(self, cmd: str) -> None:
_cprint(f" Invalid goal: {exc}")
return

_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
_cprint(f" ⊙ Goal set ({goal_budget_label(state.max_turns)}): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
Expand All @@ -2176,7 +2176,7 @@ def _handle_goal_draft(self, objective: str) -> None:
set it as the active goal. Falls back to a bare goal if the aux model
can't produce a contract."""
from cli import _DIM, _RST, _cprint
from hermes_cli.goals import draft_contract
from hermes_cli.goals import draft_contract, goal_budget_label

mgr = self._get_goal_manager()
if mgr is None:
Expand All @@ -2197,7 +2197,7 @@ def _handle_goal_draft(self, objective: str) -> None:
_cprint(f" Invalid goal: {exc}")
return

_cprint(f" ⊙ Goal set ({state.max_turns}-turn budget): {state.goal}")
_cprint(f" ⊙ Goal set ({goal_budget_label(state.max_turns)}): {state.goal}")
if state.has_contract():
_cprint(f" {_DIM}Drafted completion contract:{_RST}")
for line in state.contract.render_block().splitlines():
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2412,7 +2412,20 @@ def _ensure_hermes_home_managed(home: Path):
# asks the user to /goal resume. Protects against judge false
# negatives (goal actually done but judge says continue) and
# unbounded model spend on fuzzy / unachievable goals.
#
# Sentinel for an UNBOUNDED budget: set 0, a negative int, or the
# string "unbounded"/"infinite"/"none" to disable the turn cap — the
# loop then runs until the judge says done, you /goal clear, or
# preemption. Any positive int is a finite budget (unchanged).
"max_turns": 20,
# Whether the goal judge's verdicts may end the loop. Default true.
# Set false to strip the judge of its power to terminate: it still
# runs each turn (its reason stays in the continuation banner as a
# diagnostic) but a "done" verdict is coerced to "continue". Combined
# with max_turns: 0 this gives a fully-unbounded goal loop bounded
# only by an explicit stop — the supported form of the "infinity"
# pattern, replacing an external always-continue judge shim.
"judge_enabled": True,
},

# Mixture of Agents — named presets used by /moa. A preset is an execution
Expand Down
157 changes: 149 additions & 8 deletions hermes_cli/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,12 @@ class GoalState:
goal: str
status: str = "active" # active | paused | done | cleared
turns_used: int = 0
max_turns: int = DEFAULT_MAX_TURNS
# Turn budget. ``None`` = unbounded (the budget check is skipped and the
# loop is bounded only by the judge / user / preemption). A positive int
# is a finite budget. ``0`` never reaches here — resolve_goal_max_turns
# maps the 0/negative/unbounded-string config sentinel to ``None`` before
# state is constructed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why Optional[int] and not a magic large int: 10**6 still bounds — it just lies about intent and produces banners like 3/1000000. None is the only honest representation of "no budget," and every consumer (_fmt_turns, goal_budget_label, the budget check) is None-aware by construction.

max_turns: Optional[int] = DEFAULT_MAX_TURNS
created_at: float = 0.0
last_turn_at: float = 0.0
last_verdict: Optional[str] = None # "done" | "continue" | "skipped"
Expand Down Expand Up @@ -455,11 +460,22 @@ def from_json(cls, raw: str) -> "GoalState":
subgoals: List[str] = []
if isinstance(raw_subgoals, list):
subgoals = [str(s).strip() for s in raw_subgoals if str(s).strip()]
# max_turns may be None (unbounded) in new rows; old rows carry a
# finite int. Preserve None explicitly — the ``or DEFAULT`` idiom
# would otherwise resurrect an unbounded budget as DEFAULT_MAX_TURNS.
raw_max_turns = data.get("max_turns", DEFAULT_MAX_TURNS)
# Route through the resolver so a literal ``0``/negative/string sentinel
# in a hand-edited state file collapses to ``None`` (unbounded) rather
# than surviving as a finite-0 budget that would immediately fire
# ``0 >= 0``. ``None`` (unbounded) is preserved by the first branch;
# positive ints pass through; junk/unrecognised values fall back to
# ``DEFAULT_MAX_TURNS`` via the resolver.
max_turns = None if raw_max_turns is None else resolve_goal_max_turns(raw_max_turns)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why route deserialization through the resolver: a hand-edited or legacy state file can carry max_turns: 0. The old int(...) or DEFAULT would resurrect that as 20; a naive int(0) would create a finite-0 budget that instantly fires 0 >= 0. This line makes the finite-0 budget unconstructible from persisted state. (Surfaced in adversarial review as a dormant fail-closed path; hardened here and pinned by regression tests in test_goal_unbounded_budget.py.)

return cls(
goal=data.get("goal", ""),
status=data.get("status", "active"),
turns_used=int(data.get("turns_used", 0) or 0),
max_turns=int(data.get("max_turns", DEFAULT_MAX_TURNS) or DEFAULT_MAX_TURNS),
max_turns=max_turns,
created_at=float(data.get("created_at", 0.0) or 0.0),
last_turn_at=float(data.get("last_turn_at", 0.0) or 0.0),
last_verdict=data.get("last_verdict"),
Expand Down Expand Up @@ -699,6 +715,100 @@ def _goal_judge_max_tokens() -> int:
pass
return DEFAULT_JUDGE_MAX_TOKENS

# Sentinel values for ``goals.max_turns`` that mean "no turn budget" — the
# loop runs until the judge says done, the user stops it, or a new message
# preempts it. ``0`` and any negative int are the machine-friendly form; the
# strings give users a self-documenting spelling in config.yaml. Anything
# else (positive int) is a normal finite budget.
_UNBOUNDED_MAX_TURNS_STRINGS = {"unbounded", "infinite", "infinity", "none", "no limit", "unlimited"}


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a resolver at all: the five read sites each carried their own copy of int(x) or 20, and Python's 0 or 20 -> 20 made the "unbounded" sentinel unwritable — falsiness was being used to detect absence (the NULL-vs-0 bug one layer up). One resolver is what makes the sentinel mean the same thing at every site.

Note the bool trap two branches down: isinstance(True, int) is True in Python, so without the explicit bool branch a stray max_turns: true in YAML would silently become a 1-turn budget.

def resolve_goal_max_turns(value: Any) -> Optional[int]:
"""Normalize a ``goals.max_turns`` config value to a turn budget.

Returns a positive ``int`` for a finite budget, or ``None`` for an
*unbounded* budget (the loop is then bounded only by the judge, the user,
or preemption). Unbounded is selected by ``0``, any negative int, or one
of the strings in ``_UNBOUNDED_MAX_TURNS_STRINGS``.

Resolution rules (backward-compatible):
- ``None`` / unset → ``DEFAULT_MAX_TURNS`` (the historical default).
- positive int → that many turns (unchanged behaviour).
- ``0`` or negative int → ``None`` (unbounded). Before this change a
``0``/``None`` was coerced to ``DEFAULT_MAX_TURNS`` by the ``or``
idiom, so no existing config could have meant "unbounded" — this adds
the missing sentinel without altering any valid prior setting.
- a recognised string → ``None`` (unbounded); unrecognised / junk
strings fall back to ``DEFAULT_MAX_TURNS`` rather than crashing.

The single source of truth for the sentinel so every consumer (CLI,
gateway, evaluate_after_turn) agrees; keeps the coercion in one place
instead of re-implementing the ``or DEFAULT`` idiom at each call site.
"""
if value is None:
return DEFAULT_MAX_TURNS
if isinstance(value, bool):
# bool is a subclass of int; treat True/False as "unset" rather than
# as 1/0 so a stray boolean can't silently mean "1 turn" or "unbounded".
return DEFAULT_MAX_TURNS
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str):
s = value.strip().lower()
if not s:
return DEFAULT_MAX_TURNS
if s in _UNBOUNDED_MAX_TURNS_STRINGS:
return None
try:
n = int(s)
except ValueError:
return DEFAULT_MAX_TURNS
return n if n > 0 else None
# Unknown type — be conservative and keep the historical default.
return DEFAULT_MAX_TURNS


def goal_judge_enabled(cfg: Optional[Dict[str, Any]] = None) -> bool:
"""Whether the goal judge's verdicts are allowed to end the loop.

Reads ``goals.judge_enabled`` (default ``True``). When ``False``, the
judge still runs each turn (its ``reason`` text remains a useful
diagnostic in the continuation banner) but its ``done`` verdict is
coerced to ``continue`` — the loop can then only end via the turn budget,
an explicit user stop, or preemption. This is the supported, config-gated
way to run an effectively-unbounded goal loop (e.g. the "infinity"
pattern) without pointing the judge at an external always-continue shim.

Fail-open to ``True`` on any error or missing config so a misconfigured
value can never silently disable the judge that bounds a normal goal.
"""
try:
if cfg is None:
from hermes_cli.config import load_config

cfg = load_config()
value = (cfg.get("goals") or {}).get("judge_enabled", True)
# Accept only explicit booleans / common truthy-falsy spellings; any
# unrecognised value defaults to enabled (the safe, bounding choice).
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() not in {"false", "no", "off", "0", "disabled"}
return bool(value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not fail-safe for malformed YAML: null, [], and {} all make bool(value) false, so they disable the judge and cause the done-to-continue coercion below. Accept only explicit supported false encodings; return True for all other types/values, with regression tests for null and collections.

except Exception:
return True


def _fmt_turns(turns_used: int, max_turns: Optional[int]) -> str:
"""Render ``turns_used/max_turns`` for goal banners, ``N/∞`` when unbounded."""
return f"{turns_used}/{max_turns if max_turns is not None else '∞'}"


def goal_budget_label(max_turns: Optional[int]) -> str:
"""Human label for a goal's turn budget: ``"20-turn budget"`` or
``"unbounded budget"`` when ``max_turns`` is None."""
return f"{max_turns}-turn budget" if max_turns is not None else "unbounded budget"


def _parse_judge_response(raw: str) -> Tuple[str, str, bool, Optional[Dict[str, Any]]]:
"""Parse the judge's reply. Fail-open on unusable output.
Expand Down Expand Up @@ -1093,9 +1203,12 @@ class GoalManager:
feed back into ``run_conversation``.
"""

def __init__(self, session_id: str, *, default_max_turns: int = DEFAULT_MAX_TURNS):
def __init__(self, session_id: str, *, default_max_turns: Optional[int] = DEFAULT_MAX_TURNS):
self.session_id = session_id
self.default_max_turns = int(default_max_turns or DEFAULT_MAX_TURNS)
# ``None`` = unbounded budget (from resolve_goal_max_turns). Preserve it
# rather than coercing through ``or DEFAULT`` — an explicit unbounded
# setting must survive to the GoalState.
self.default_max_turns = None if default_max_turns is None else int(default_max_turns or DEFAULT_MAX_TURNS)
self._state: Optional[GoalState] = load_goal(session_id)

# --- introspection ------------------------------------------------
Expand All @@ -1117,7 +1230,7 @@ def status_line(self) -> str:
s = self._state
if s is None or s.status in {"cleared",}:
return "No active goal. Set one with /goal <text>."
turns = f"{s.turns_used}/{s.max_turns} turns"
turns = f"{_fmt_turns(s.turns_used, s.max_turns)} turns"
sub = f", {len(s.subgoals)} subgoal{'s' if len(s.subgoals) != 1 else ''}" if s.subgoals else ""
con = ", contract" if self.has_contract() else ""
meta = f"{turns}{sub}{con}"
Expand Down Expand Up @@ -1146,11 +1259,18 @@ def set(self, goal: str, *, max_turns: Optional[int] = None, contract: Optional[
goal = (goal or "").strip()
if not goal:
raise ValueError("goal text is empty")
# Resolve the effective budget: an explicit per-goal max_turns wins;
# otherwise fall back to the manager default. Either may be None
# (unbounded). Route an explicit value through ``resolve_goal_max_turns``
# so a literal ``0``/negative sentinel collapses to ``None`` (unbounded)
# instead of surviving as a finite-0 budget that would immediately fire
# ``0 >= 0``; a ``None`` argument means "use the manager default".
effective_max_turns = self.default_max_turns if max_turns is None else resolve_goal_max_turns(max_turns)
state = GoalState(
goal=goal,
status="active",
turns_used=0,
max_turns=int(max_turns) if max_turns else self.default_max_turns,
max_turns=effective_max_turns,
created_at=time.time(),
last_turn_at=0.0,
contract=contract if contract is not None else GoalContract(),
Expand Down Expand Up @@ -1453,6 +1573,15 @@ def evaluate_after_turn(
state.last_verdict = verdict
state.last_reason = reason

# Judge kill-switch: when goals.judge_enabled is false the judge may
# not end the loop. Coerce a done verdict to continue (keeping its
# reason as a diagnostic in the continuation banner); wait/continue
# are unaffected. The budget, an explicit user stop, and preemption
# remain as exits — the judge simply loses its power to terminate.
if verdict == "done" and not goal_judge_enabled():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering is the invariant: state.last_verdict (two lines up) records the judge's true verdict before this coercion rewrites the local. An operator who disarms the judge keeps full observability into where the judge would have stopped — the judge is demoted from guillotine to advisor, not silenced. The coercion also sits after the 5-tuple unpack so upstream's transport_failed handling below is untouched.

verdict = "continue"
reason = f"[judge disabled] would-be done: {reason}"

# Track consecutive judge parse failures. Reset on any usable reply,
# including API / transport errors (parse_failed=False) so a flaky
# network doesn't trip the auto-pause meant for bad judge models.
Expand Down Expand Up @@ -1568,7 +1697,9 @@ def evaluate_after_turn(
),
}

if state.turns_used >= state.max_turns:
# Budget exit — skipped entirely when max_turns is None (unbounded):
# the loop is then bounded only by the judge, the user, or preemption.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is not None and not truthiness: None (unbounded) must skip this exit entirely — a bare >= would TypeError, and any or-style guard would silently re-impose 20 on an operator's explicit unbounded setting. By the resolver invariant, a literal 0 can never reach this line, so the guard only ever compares a positive int.

if state.max_turns is not None and state.turns_used >= state.max_turns:
state.status = "paused"
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
save_goal(self.session_id, state)
Expand All @@ -1592,7 +1723,7 @@ def evaluate_after_turn(
"verdict": "continue",
"reason": reason,
"message": (
f"↻ Continuing toward goal ({state.turns_used}/{state.max_turns}): {reason}"
f"↻ Continuing toward goal ({_fmt_turns(state.turns_used, state.max_turns)}): {reason}"
),
}

Expand Down Expand Up @@ -1709,6 +1840,12 @@ def _log(msg: str) -> None:
if max_turns < 1:
max_turns = DEFAULT_MAX_TURNS

# Judge kill-switch (read once per worker run): when goals.judge_enabled
# is false the judge may not end the loop — a done verdict is coerced to
# continue so the worker keeps going until its budget, an explicit
# kanban_complete/kanban_block, or a stop. Same gate as the main loop.
_judge_enabled = goal_judge_enabled()

last_response = first_response or ""
# The first turn already consumed one unit of budget.
turns_used = 1
Expand Down Expand Up @@ -1740,6 +1877,10 @@ def _log(msg: str) -> None:
verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response)
if verdict == "wait":
verdict = "continue"
# Judge kill-switch: a disabled judge cannot end the kanban loop.
if verdict == "done" and not _judge_enabled:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same gate as the main loop — a kill-switch covering one of two loops is a lie. _judge_enabled is captured once per worker run rather than per turn: re-reading config every iteration would pay IO per turn and, worse, let a mid-run config edit produce a loop that honors some done verdicts and coerces others.

Disclosed gap (PR body §6): this path's max_turns normalization above still applies its own ... or DEFAULT collapse; the closing commit is prepared.

verdict = "continue"
reason = f"[judge disabled] would-be done: {reason}"
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")

if verdict == "done":
Expand Down
1 change: 1 addition & 0 deletions locales/af.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ gateway:
resumed: "▶ Doelwit hervat: {goal}\nStuur enige boodskap om voort te gaan, of wag — ek sal die volgende stap met die volgende beurt neem."
invalid: "Ongeldige doelwit: {error}"
set: "⊙ Doelwit gestel ({budget}-beurt-begroting): {goal}\nEk sal aanhou werk totdat die doelwit klaar is, jy dit pouseer/verwyder, of die begroting opgebruik is.\nBeheer: /goal status · /goal pause · /goal resume · /goal clear"
set_unbounded: "⊙ Doelwit gestel (onbegrensde begroting): {goal}\nEk sal aanhou werk totdat die beoordelaar sê dit is klaar of jy dit pouseer/verwyder — daar is geen beurtbeperking nie.\nBeheer: /goal status · /goal pause · /goal resume · /goal clear"

help:
header: "📖 **Hermes-opdragte**\n"
Expand Down
Loading
Loading