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
4 changes: 3 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9186,9 +9186,11 @@ def _get_goal_manager(self):
return existing

try:
from hermes_cli.goals import _coerce_goal_turn_cap

cfg = load_config() or {}
goals_cfg = cfg.get("goals") or {}
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
max_turns = _coerce_goal_turn_cap(goals_cfg.get("max_turns", 20))
except Exception:
max_turns = 20

Expand Down
4 changes: 3 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10685,7 +10685,9 @@ 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)
from hermes_cli.goals import _coerce_goal_turn_cap

return _coerce_goal_turn_cap(goals_cfg.get("max_turns", 20))
except Exception:
return 20

Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,8 +1353,10 @@ def _ensure_hermes_home_managed(home: Path):
# Max continuation turns before Hermes auto-pauses the goal and
# 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.
"max_turns": 20,
# unbounded model spend on fuzzy / unachievable goals. This is a
# ceiling: simple goals still default to 20 turns, while complex
# plan-style prompts automatically scale up, capped at 250.
"max_turns": 250,
},

# Skills — external skill directories for sharing skills across tools/agents.
Expand Down
106 changes: 104 additions & 2 deletions hermes_cli/goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
# ──────────────────────────────────────────────────────────────────────

DEFAULT_MAX_TURNS = 20
MAX_GOAL_TURNS = 250
DEFAULT_JUDGE_TIMEOUT = 30.0
# Judge output budget. The freeform judge returns a one-line JSON verdict, but
# reasoning models (deepseek-v4, qwq, etc.) burn tokens on hidden reasoning
Expand All @@ -68,6 +69,103 @@
DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3


def _coerce_goal_turn_cap(value: Any, default: int = DEFAULT_MAX_TURNS) -> int:
"""Return a positive /goal turn cap, clamped to ``MAX_GOAL_TURNS``.

``goals.max_turns`` is the user's safety ceiling, not necessarily the
budget every goal should consume. Keep malformed/non-positive values safe,
and never allow a standing goal to silently exceed the hard upper bound.
"""
try:
turns = int(value)
except Exception:
turns = int(default or DEFAULT_MAX_TURNS)
if turns <= 0:
turns = int(default or DEFAULT_MAX_TURNS)
return max(1, min(turns, MAX_GOAL_TURNS))


def infer_goal_turn_budget(goal: str, *, cap: int = MAX_GOAL_TURNS) -> int:
"""Infer a per-goal turn budget from the user's goal prompt.

The configured ``goals.max_turns`` is now treated as the ceiling. Short,
simple goals keep the historical 20-turn budget; long implementation plans
and prompts that explicitly ask for persistence ("infinite turns", "all
specs", many tests/phases, etc.) scale up automatically, capped at 250.
"""
text = (goal or "").strip()
if not text:
return min(DEFAULT_MAX_TURNS, _coerce_goal_turn_cap(cap))

cap = _coerce_goal_turn_cap(cap, MAX_GOAL_TURNS)
lower = text.lower()

# Explicit turn requests in the goal text win, but stay within the cap.
explicit_turns = re.findall(r"\b(\d{1,4})\s*(?:turn|turns|iterations?)\b", lower)
if explicit_turns:
return max(1, min(max(int(n) for n in explicit_turns), cap))

if any(phrase in lower for phrase in (
"infinite turns",
"as many turns",
"however many turns",
"until complete",
"don't stop",
"do not stop",
"keep working until",
)):
return cap

budget = DEFAULT_MAX_TURNS
length = len(text)
if length >= 4000:
budget = max(budget, 200)
elif length >= 2000:
budget = max(budget, 150)
elif length >= 1000:
budget = max(budget, 100)
elif length >= 500:
budget = max(budget, 60)

# Multi-phase implementation plans and hard verification requirements need
# more runway than a short Q&A, even when the prompt itself is not huge.
high_runway_markers = (
"all specs",
"implementation plan",
"execute the plan",
"test 20",
"20 different ways",
"adversarial",
"sandbox",
"broad rollout",
"production rollout",
"definition of done",
"non-negotiable",
)
if any(marker in lower for marker in high_runway_markers):
budget = max(budget, 150)

phase_count = len(re.findall(r"\bphase\s+\d+\b", lower))
if phase_count >= 6:
budget = max(budget, 200)
elif phase_count >= 3:
budget = max(budget, 120)

# Scale for concrete checklist density.
checklist_lines = sum(
1 for line in text.splitlines()
if re.match(r"\s*(?:[-*]|\d+[.)])\s+\S", line)
)
if checklist_lines >= 30:
budget = max(budget, 200)
elif checklist_lines >= 15:
budget = max(budget, 120)
elif checklist_lines >= 8:
budget = max(budget, 80)

return max(1, min(budget, cap))


CONTINUATION_PROMPT_TEMPLATE = (
"[Continuing toward your standing goal]\n"
"Goal: {goal}\n\n"
Expand Down Expand Up @@ -487,7 +585,7 @@ class GoalManager:

def __init__(self, session_id: str, *, default_max_turns: int = DEFAULT_MAX_TURNS):
self.session_id = session_id
self.default_max_turns = int(default_max_turns or DEFAULT_MAX_TURNS)
self.default_max_turns = _coerce_goal_turn_cap(default_max_turns)
self._state: Optional[GoalState] = load_goal(session_id)

# --- introspection ------------------------------------------------
Expand Down Expand Up @@ -527,7 +625,11 @@ def set(self, goal: str, *, max_turns: Optional[int] = None) -> GoalState:
goal=goal,
status="active",
turns_used=0,
max_turns=int(max_turns) if max_turns else self.default_max_turns,
max_turns=(
_coerce_goal_turn_cap(max_turns, self.default_max_turns)
if max_turns
else infer_goal_turn_budget(goal, cap=self.default_max_turns)
),
created_at=time.time(),
last_turn_at=0.0,
)
Expand Down
34 changes: 34 additions & 0 deletions tests/hermes_cli/test_goals.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,40 @@ def test_set_then_status(self, hermes_home):
assert "active" in mgr.status_line().lower()
assert "port the thing" in mgr.status_line()

def test_simple_goal_uses_historical_20_turn_budget_under_high_cap(self, hermes_home):
from hermes_cli.goals import GoalManager

mgr = GoalManager(session_id="test-simple-budget", default_max_turns=250)
state = mgr.set("summarize the docs")
assert state.max_turns == 20

def test_complex_goal_prompt_scales_to_cap(self, hermes_home):
from hermes_cli.goals import GoalManager

mgr = GoalManager(session_id="test-complex-budget", default_max_turns=250)
state = mgr.set(
"execute the following implementation plan to all specs; "
"test 20 different ways; you have infinite turns to accomplish the goal"
)
assert state.max_turns == 250

def test_explicit_goal_turns_are_capped_at_250(self, hermes_home):
from hermes_cli.goals import GoalManager

mgr = GoalManager(session_id="test-explicit-budget", default_max_turns=500)
state = mgr.set("run this for 999 turns if needed")
assert state.max_turns == 250

def test_long_plan_prompt_scales_but_respects_configured_cap(self, hermes_home):
from hermes_cli.goals import GoalManager

mgr = GoalManager(session_id="test-config-cap", default_max_turns=80)
long_plan = "Implementation plan\n" + "\n".join(
f"- Phase {i}: do verified work" for i in range(1, 10)
)
state = mgr.set(long_plan)
assert state.max_turns == 80

def test_set_rejects_empty(self, hermes_home):
from hermes_cli.goals import GoalManager

Expand Down
4 changes: 2 additions & 2 deletions website/docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/stop` | Kill all running background processes |
| `/queue <prompt>` (alias: `/q`) | Queue a prompt for the next turn (doesn't interrupt the current agent response). |
| `/steer <prompt>` | Inject a mid-run note that arrives at the agent **after the next tool call** — no interrupt, no new user turn. The text is appended to the last tool result's content once the current tool completes, giving the agent new context without breaking the current tool-calling loop. Use this to nudge direction mid-task (e.g. "focus on the auth module" while the agent is running tests). |
| `/goal <text>` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. After each turn an auxiliary judge model decides whether the goal is done; if not, Hermes auto-continues. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Budget defaults to 20 turns (`goals.max_turns`); any real user message preempts the continuation loop, and state survives `/resume`. See [Persistent Goals](/user-guide/features/goals) for the full walkthrough. |
| `/goal <text>` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. After each turn an auxiliary judge model decides whether the goal is done; if not, Hermes auto-continues. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Simple goals use 20 turns; complex plan-style prompts scale automatically up to the configured ceiling (`goals.max_turns`, default/max 250). Any real user message preempts the continuation loop, and state survives `/resume`. See [Persistent Goals](/user-guide/features/goals) for the full walkthrough. |
| `/subgoal <text>` | Append a user-supplied criterion to the active goal mid-loop. The continuation prompt surfaces all subgoals to the agent verbatim, and the judge factors them into its DONE/CONTINUE verdict — so the goal isn't marked done until the original goal **and** every subgoal are met. Subcommands: `/subgoal` (list), `/subgoal remove <N>`, `/subgoal clear`. Requires an active `/goal`. |
| `/resume [name]` | Resume a previously-named session |
| `/sessions` | Browse and resume previous sessions in an interactive picker |
Expand Down Expand Up @@ -216,7 +216,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/background <prompt>` | Run a prompt in a separate background session. Results are delivered back to the same chat when the task finishes. See [Messaging Background Sessions](/user-guide/messaging/#background-sessions). |
| `/queue <prompt>` (alias: `/q`) | Queue a prompt for the next turn without interrupting the current one. |
| `/steer <prompt>` | Inject a message after the next tool call without interrupting — the model picks it up on its next iteration rather than as a new turn. |
| `/goal <text>` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. A judge model checks after each turn; if not done, Hermes auto-continues until it is, you pause/clear it, or the turn budget (default 20) is hit. Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Safe to run mid-agent for status/pause/clear; setting a new goal requires `/stop` first. See [Persistent Goals](/user-guide/features/goals). |
| `/goal <text>` | Set a standing goal Hermes works toward across turns — our take on the Ralph loop. A judge model checks after each turn; if not done, Hermes auto-continues until it is, you pause/clear it, or the inferred turn budget is hit. Simple goals use 20 turns; complex plan-style prompts scale automatically up to `goals.max_turns` (default/max 250). Subcommands: `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. Safe to run mid-agent for status/pause/clear; setting a new goal requires `/stop` first. See [Persistent Goals](/user-guide/features/goals). |
| `/footer [on\|off\|status]` | Toggle the runtime-metadata footer on final replies (shows model, tool counts, timing). |
| `/curator [status\|run\|pin\|archive]` | Background skill maintenance controls. |
| `/kanban <action>` | Drive the multi-profile, multi-project collaboration board from chat — identical argument surface to the CLI. Bypasses the running-agent guard, so `/kanban unblock t_abc`, `/kanban comment t_abc "…"`, `/kanban list --mine`, `/kanban boards switch <slug>`, etc. work mid-turn. `/kanban create …` auto-subscribes the originating chat to the new task's terminal events. See [Kanban slash command](/user-guide/features/kanban#kanban-slash-command). |
Expand Down
18 changes: 9 additions & 9 deletions website/docs/user-guide/features/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ Tasks where the agent does one turn and stops don't need `/goal`. Tasks where *y

What you'll see:

1. **Goal accepted** — `⊙ Goal set (20-turn budget): <your goal>`
1. **Goal accepted** — `⊙ Goal set (<budget>-turn budget): <your goal>`
2. **Turn 1 runs** — Hermes starts working as if you'd sent the goal as a normal message.
3. **Judge runs** — after the turn, the judge model decides `done` or `continue`.
4. **Loop fires if needed** — if `continue`, you'll see `↻ Continuing toward goal (1/20): <judge's reason>` and Hermes takes the next step automatically.
5. **Terminates** — eventually you see either `✓ Goal achieved: <reason>` or `⏸ Goal paused — N/20 turns used`.
4. **Loop fires if needed** — if `continue`, you'll see `↻ Continuing toward goal (1/<budget>): <judge's reason>` and Hermes takes the next step automatically.
5. **Terminates** — eventually you see either `✓ Goal achieved: <reason>` or `⏸ Goal paused — N/<budget> turns used`.

## Commands

Expand Down Expand Up @@ -80,7 +80,7 @@ If the judge errors (network blip, malformed response, unavailable aux client),

### 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:
Default simple goals use 20 continuation turns. `goals.max_turns` in `config.yaml` is a ceiling (default/max 250): complex plan-style prompts automatically scale their budget from the goal text, up to that cap. When the budget is hit, Hermes auto-pauses and tells you exactly how to proceed:

```
⏸ Goal paused — 20/20 turns used. Use /goal resume to keep going, or /goal clear to stop.
Expand All @@ -102,18 +102,18 @@ Goal state lives in `SessionDB.state_meta` keyed by `goal:<session_id>`. That me

### Prompt cache

The continuation prompt is a plain user-role message appended to history. It does **not** mutate the system prompt, swap toolsets, or touch the conversation in any way that invalidates Hermes' prompt cache. Running a 20-turn goal costs the same cache-wise as 20 turns of normal conversation.
The continuation prompt is a plain user-role message appended to history. It does **not** mutate the system prompt, swap toolsets, or touch the conversation in any way that invalidates Hermes' prompt cache. Running a multi-turn goal costs the same cache-wise as the same number of normal conversation turns.

## Configuration

Add to `~/.hermes/config.yaml`:

```yaml
goals:
# Max continuation turns before Hermes auto-pauses and asks you to
# /goal resume. Default 20. Lower this if you want tighter loops;
# raise it for long-running refactors.
max_turns: 20
# Ceiling for auto-continuation turns before Hermes auto-pauses and asks
# you to /goal resume. Simple goals still use 20 turns; complex plan-style
# prompts scale automatically from the goal text, capped here. Default/max 250.
max_turns: 250
```

### Choosing the judge model
Expand Down