Skip to content
Closed
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
29 changes: 21 additions & 8 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6787,9 +6787,10 @@ def _handle_reasoning_command(self, cmd: str):
/reasoning show|on Show model thinking/reasoning in output
/reasoning hide|off Hide model thinking/reasoning from output
"""
parts = cmd.strip().split(maxsplit=1)
parts = cmd.strip().split(None, 1)
raw_args = parts[1].strip() if len(parts) > 1 else ""

if len(parts) < 2:
if not raw_args:
# Show current state
rc = self.reasoning_config
if rc is None:
Expand All @@ -6801,10 +6802,18 @@ def _handle_reasoning_command(self, cmd: str):
display_state = "on ✓" if self.show_reasoning else "off"
_cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}")
_cprint(f" {_ACCENT}Reasoning display: {display_state}{_RST}")
_cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide>{_RST}")
_cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide> [--global]{_RST}")
return

arg = parts[1].strip().lower()
from hermes_constants import parse_reasoning_command_args

arg, persist_global, parse_error = parse_reasoning_command_args(raw_args)
if parse_error:
_cprint(f" {_DIM}(._.) {parse_error}{_RST}")
_cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}")
_cprint(f" {_DIM}Display: show, hide{_RST}")
_cprint(f" {_DIM}Flag: --global{_RST}")
return

# Display toggle
if arg in ("show", "on"):
Expand All @@ -6826,18 +6835,22 @@ def _handle_reasoning_command(self, cmd: str):
# Effort level change
parsed = _parse_reasoning_config(arg)
if parsed is None:
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
_cprint(f" {_DIM}(._.) Unknown argument: {raw_args.lower()}{_RST}")
_cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}")
_cprint(f" {_DIM}Display: show, hide{_RST}")
_cprint(f" {_DIM}Flag: --global{_RST}")
return

self.reasoning_config = parsed
self.agent = None # Force agent re-init with new reasoning config

if save_config_value("agent.reasoning_effort", arg):
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
if persist_global:
if save_config_value("agent.reasoning_effort", arg):
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (saved to config){_RST}")
else:
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only; config save failed){_RST}")
else:
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only){_RST}")
_cprint(f" {_ACCENT}✓ Reasoning effort set to '{arg}' (session only — add --global to persist){_RST}")

def _handle_fast_command(self, cmd: str):
"""Handle /fast — toggle fast mode (OpenAI Priority Processing / Anthropic Fast Mode)."""
Expand Down
22 changes: 21 additions & 1 deletion cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from hermes_constants import get_hermes_home
from hermes_constants import get_hermes_home, parse_reasoning_effort
from typing import Optional, Dict, List, Any

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,6 +61,19 @@ def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = N
return normalized


def _normalize_reasoning_effort(reasoning_effort: Optional[Any]) -> Optional[str]:
if reasoning_effort is None:
return None
text = str(reasoning_effort).strip().lower()
if not text:
return None
if parse_reasoning_effort(text) is None:
raise ValueError(
"Invalid reasoning_effort. Expected one of: none, minimal, low, medium, high, xhigh."
)
return text


def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:
"""Return a job dict with canonical `skills` and legacy `skill` fields aligned."""
normalized = dict(job)
Expand Down Expand Up @@ -383,6 +396,7 @@ def create_job(
model: Optional[str] = None,
provider: Optional[str] = None,
base_url: Optional[str] = None,
reasoning_effort: Optional[str] = None,
script: Optional[str] = None,
) -> Dict[str, Any]:
"""
Expand All @@ -400,6 +414,7 @@ def create_job(
model: Optional per-job model override
provider: Optional per-job provider override
base_url: Optional per-job base URL override
reasoning_effort: Optional per-job reasoning override
script: Optional path to a Python script whose stdout is injected into the
prompt each run. The script runs before the agent turn, and its output
is prepended as context. Useful for data collection / change detection.
Expand Down Expand Up @@ -428,6 +443,7 @@ def create_job(
normalized_model = str(model).strip() if isinstance(model, str) else None
normalized_provider = str(provider).strip() if isinstance(provider, str) else None
normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None
normalized_reasoning_effort = _normalize_reasoning_effort(reasoning_effort)
normalized_model = normalized_model or None
normalized_provider = normalized_provider or None
normalized_base_url = normalized_base_url or None
Expand All @@ -444,6 +460,7 @@ def create_job(
"model": normalized_model,
"provider": normalized_provider,
"base_url": normalized_base_url,
"reasoning_effort": normalized_reasoning_effort,
"script": normalized_script,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
Expand Down Expand Up @@ -500,6 +517,9 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates

if "reasoning_effort" in updates:
updated["reasoning_effort"] = _normalize_reasoning_effort(updated.get("reasoning_effort"))

if "skills" in updates or "skill" in updates:
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
updated["skills"] = normalized_skills
Expand Down
4 changes: 2 additions & 2 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,9 +808,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except Exception:
pass

# Reasoning config from config.yaml
# Reasoning config from job override or config.yaml
from hermes_constants import parse_reasoning_effort
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
effort = str(job.get("reasoning_effort") or _cfg.get("agent", {}).get("reasoning_effort", "")).strip()
reasoning_config = parse_reasoning_effort(effort)

# Prefill messages from env or config.yaml
Expand Down
84 changes: 66 additions & 18 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ class GatewayRunner:
_restart_via_service: bool = False
_stop_task: Optional[asyncio.Task] = None
_session_model_overrides: Dict[str, Dict[str, str]] = {}
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}

def __init__(self, config: Optional[GatewayConfig] = None):
self.config = config or load_gateway_config()
Expand Down Expand Up @@ -678,6 +679,9 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# Per-session model overrides from /model command.
# Key: session_key, Value: dict with model/provider/api_key/base_url/api_mode
self._session_model_overrides: Dict[str, Dict[str, str]] = {}
# Per-session reasoning overrides from /reasoning command.
# Key: session_key, Value: dict like {"enabled": bool, "effort": str?}
self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
# Track pending exec approvals per session
# Key: session_key, Value: {"command": str, "pattern_key": str, ...}
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
Expand Down Expand Up @@ -1344,6 +1348,30 @@ def _load_reasoning_config() -> dict | None:
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
return result

def _get_reasoning_config_for_session(
self,
*,
source: Optional[SessionSource] = None,
session_key: Optional[str] = None,
) -> dict | None:
"""Return the effective reasoning config for a session.

Session-scoped `/reasoning` overrides win over config.yaml. When no
override exists, fall back to the persisted global config.
"""
resolved_session_key = session_key
if not resolved_session_key and source is not None:
try:
resolved_session_key = self._session_key_for_source(source)
except Exception:
resolved_session_key = None

overrides = getattr(self, "_session_reasoning_overrides", {}) or {}
override = overrides.get(resolved_session_key) if resolved_session_key else None
if override is not None:
return dict(override)
return self._load_reasoning_config()

@staticmethod
def _load_service_tier() -> str | None:
"""Load Priority Processing setting from config.yaml.
Expand Down Expand Up @@ -4613,6 +4641,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
self.session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
self._session_reasoning_overrides.pop(session_key, None)
response = (response or "") + (
"\n\n🔄 Session auto-reset — the conversation exceeded the "
"maximum context size and could not be compressed further. "
Expand Down Expand Up @@ -4900,6 +4929,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
# Clear any session-scoped model override so the next agent picks up
# the configured default instead of the previously switched model.
self._session_model_overrides.pop(session_key, None)
self._session_reasoning_overrides.pop(session_key, None)

# Fire plugin on_session_finalize hook (session boundary)
try:
Expand Down Expand Up @@ -6431,7 +6461,7 @@ async def _run_background_task(

pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
reasoning_config = self._load_reasoning_config()
reasoning_config = self._get_reasoning_config_for_session(source=source)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
Expand Down Expand Up @@ -6599,7 +6629,7 @@ async def _run_btw_task(
return

platform_key = _platform_config_key(source.platform)
reasoning_config = self._load_reasoning_config()
reasoning_config = self._get_reasoning_config_for_session(session_key=session_key)
self._service_tier = self._load_service_tier()
turn_route = self._resolve_turn_agent_config(question, model, runtime_kwargs)
pr = self._provider_routing
Expand Down Expand Up @@ -6711,10 +6741,12 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str:
/reasoning hide|off Hide model reasoning from responses
"""
import yaml
from hermes_constants import parse_reasoning_command_args, parse_reasoning_effort

args = event.get_command_args().strip().lower()
raw_args = event.get_command_args().strip()
config_path = _hermes_home / "config.yaml"
self._reasoning_config = self._load_reasoning_config()
session_key = self._session_key_for_source(event.source)
self._reasoning_config = self._get_reasoning_config_for_session(session_key=session_key)
self._show_reasoning = self._load_show_reasoning()

def _save_config_key(key_path: str, value):
Expand All @@ -6737,7 +6769,7 @@ def _save_config_key(key_path: str, value):
logger.error("Failed to save config key %s: %s", key_path, e)
return False

if not args:
if not raw_args:
# Show current state
rc = self._reasoning_config
if rc is None:
Expand All @@ -6751,7 +6783,16 @@ def _save_config_key(key_path: str, value):
"🧠 **Reasoning Settings**\n\n"
f"**Effort:** `{level}`\n"
f"**Display:** {display_state}\n\n"
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide>`"
"_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|show|hide> [--global]`"
)

args, persist_global, parse_error = parse_reasoning_command_args(raw_args)
if parse_error:
return (
f"⚠️ {parse_error}\n\n"
"**Valid levels:** none, minimal, low, medium, high, xhigh\n"
"**Display:** show, hide\n"
"**Flag:** --global"
)

# Display toggle (per-platform)
Expand All @@ -6771,22 +6812,29 @@ def _save_config_key(key_path: str, value):

# Effort level change
effort = args.strip()
if effort == "none":
parsed = {"enabled": False}
elif effort in ("minimal", "low", "medium", "high", "xhigh"):
parsed = {"enabled": True, "effort": effort}
else:
parsed = parse_reasoning_effort(effort)
if parsed is None:
return (
f"⚠️ Unknown argument: `{effort}`\n\n"
f"⚠️ Unknown argument: `{raw_args}`\n\n"
"**Valid levels:** none, minimal, low, medium, high, xhigh\n"
"**Display:** show, hide"
"**Display:** show, hide\n"
"**Flag:** --global"
)

self._reasoning_config = parsed
if _save_config_key("agent.reasoning_effort", effort):
return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_"
else:
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
session_overrides = getattr(self, "_session_reasoning_overrides", None)
if session_overrides is None:
session_overrides = self._session_reasoning_overrides = {}

if persist_global:
session_overrides.pop(session_key, None)
if _save_config_key("agent.reasoning_effort", effort):
return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_"
session_overrides[session_key] = parsed
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only; config save failed)"

session_overrides[session_key] = parsed
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only — use `--global` to persist)"

async def _handle_fast_command(self, event: MessageEvent) -> str:
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
Expand Down Expand Up @@ -9551,7 +9599,7 @@ def run_sync():
}

pr = self._provider_routing
reasoning_config = self._load_reasoning_config()
reasoning_config = self._get_reasoning_config_for_session(session_key=session_key)
self._reasoning_config = reasoning_config
self._service_tier = self._load_service_tier()
# Set up stream consumer for token streaming or interim commentary.
Expand Down
31 changes: 31 additions & 0 deletions hermes_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import os
import shlex
from pathlib import Path


Expand Down Expand Up @@ -141,6 +142,36 @@ def get_subprocess_home() -> str | None:
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")


def parse_reasoning_command_args(raw_args: str) -> tuple[str, bool, str | None]:
"""Parse `/reasoning` arguments into (value, persist_global, error).

Accepts one positional value plus an optional ``--global`` flag in any order.
Returns the normalized positional value (or "" when omitted), whether the
change should persist globally, and an error string for unsupported flags or
multiple positional arguments.
"""
try:
tokens = shlex.split(str(raw_args or ""))
except ValueError as exc:
return ("", False, str(exc))

value = ""
persist_global = False
for token in tokens:
normalized = str(token or "").strip().lower()
if not normalized:
continue
if normalized == "--global":
persist_global = True
continue
if normalized.startswith("--"):
return ("", persist_global, f"Unknown flag: {normalized}")
if value:
return ("", persist_global, f"Unknown argument: {' '.join(tokens)}")
value = normalized
return (value, persist_global, None)


def parse_reasoning_effort(effort: str) -> dict | None:
"""Parse a reasoning effort level into a config dict.

Expand Down
27 changes: 27 additions & 0 deletions tests/cli/test_reasoning_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,33 @@ def test_status_with_explicit_level(self):
level = rc.get("effort", "medium")
self.assertEqual(level, "xhigh")

@patch("cli._cprint")
@patch("cli.save_config_value")
def test_handle_reasoning_command_accepts_global_flag(self, mock_save, _mock_print):
from cli import HermesCLI

stub = self._make_cli()
stub._current_reasoning_callback = lambda: None
mock_save.return_value = True

HermesCLI._handle_reasoning_command(stub, "/reasoning medium --global")

self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "medium"})
mock_save.assert_called_once_with("agent.reasoning_effort", "medium")

@patch("cli._cprint")
@patch("cli.save_config_value")
def test_handle_reasoning_command_defaults_to_session_only(self, mock_save, _mock_print):
from cli import HermesCLI

stub = self._make_cli()
stub._current_reasoning_callback = lambda: None

HermesCLI._handle_reasoning_command(stub, "/reasoning high")

self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
mock_save.assert_not_called()


# ---------------------------------------------------------------------------
# Reasoning extraction and result dict
Expand Down
Loading