mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(cli): add /reasoning command for effort level and display toggle
Combined implementation of reasoning management: - /reasoning Show current effort level and display state - /reasoning <level> Set reasoning effort (none, low, medium, high, xhigh) - /reasoning show|on Show model thinking/reasoning in output - /reasoning hide|off Hide model thinking/reasoning from output Effort level changes persist to config and force agent re-init. Display toggle updates the agent callback dynamically without re-init. When display is enabled: - Intermediate reasoning shown as dim [thinking] lines during tool loops - Final reasoning shown in a bordered box above the response - Long reasoning collapsed (5 lines intermediate, 10 lines final) Also adds: - reasoning_callback parameter to AIAgent - last_reasoning in run_conversation result dict - show_reasoning config option (display section, default: false) - Display section in /config output - 34 tests covering both features Combines functionality from PR #789 and PR #790. Co-authored-by: Aum Desai <Aum08Desai@users.noreply.github.com> Co-authored-by: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com>
This commit is contained in:
parent
c837ef949d
commit
4d873f77c1
10 changed files with 560 additions and 1 deletions
|
|
@ -670,6 +670,11 @@ display:
|
|||
# Works over SSH. Most terminals can be configured to flash the taskbar or play a sound.
|
||||
bell_on_complete: false
|
||||
|
||||
# Show model reasoning/thinking before each response.
|
||||
# When enabled, a dim box shows the model's thought process above the response.
|
||||
# Toggle at runtime with /reasoning show or /reasoning hide.
|
||||
show_reasoning: false
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Skin / Theme
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
93
cli.py
93
cli.py
|
|
@ -205,6 +205,7 @@ def load_cli_config() -> Dict[str, Any]:
|
|||
"display": {
|
||||
"compact": False,
|
||||
"resume_display": "full",
|
||||
"show_reasoning": False,
|
||||
"skin": "default",
|
||||
},
|
||||
"clarify": {
|
||||
|
|
@ -1121,6 +1122,8 @@ class HermesCLI:
|
|||
self.resume_display = CLI_CONFIG["display"].get("resume_display", "full")
|
||||
# bell_on_complete: play terminal bell (\a) when agent finishes a response
|
||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||
# show_reasoning: display model thinking/reasoning before the response
|
||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
|
||||
|
||||
# Configuration - priority: CLI args > env vars > config file
|
||||
|
|
@ -1495,6 +1498,7 @@ class HermesCLI:
|
|||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
clarify_callback=self._clarify_callback,
|
||||
reasoning_callback=self._on_reasoning if self.show_reasoning else None,
|
||||
honcho_session_key=self.session_id,
|
||||
fallback_model=self._fallback_model,
|
||||
thinking_callback=self._on_thinking,
|
||||
|
|
@ -2848,6 +2852,8 @@ class HermesCLI:
|
|||
self._show_gateway_status()
|
||||
elif cmd_lower == "/verbose":
|
||||
self._toggle_verbose()
|
||||
elif cmd_lower.startswith("/reasoning"):
|
||||
self._handle_reasoning_command(cmd_original)
|
||||
elif cmd_lower == "/compress":
|
||||
self._manual_compress()
|
||||
elif cmd_lower == "/usage":
|
||||
|
|
@ -3073,6 +3079,75 @@ class HermesCLI:
|
|||
}
|
||||
self.console.print(labels.get(self.tool_progress_mode, ""))
|
||||
|
||||
def _handle_reasoning_command(self, cmd: str):
|
||||
"""Handle /reasoning — manage effort level and display toggle.
|
||||
|
||||
Usage:
|
||||
/reasoning Show current effort level and display state
|
||||
/reasoning <level> Set reasoning effort (none, low, medium, high, xhigh)
|
||||
/reasoning show|on Show model thinking/reasoning in output
|
||||
/reasoning hide|off Hide model thinking/reasoning from output
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
# Show current state
|
||||
rc = self.reasoning_config
|
||||
if rc is None:
|
||||
level = "medium (default)"
|
||||
elif rc.get("enabled") is False:
|
||||
level = "none (disabled)"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
display_state = "on" if self.show_reasoning else "off"
|
||||
_cprint(f" {_GOLD}Reasoning effort: {level}{_RST}")
|
||||
_cprint(f" {_GOLD}Reasoning display: {display_state}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /reasoning <none|low|medium|high|xhigh|show|hide>{_RST}")
|
||||
return
|
||||
|
||||
arg = parts[1].strip().lower()
|
||||
|
||||
# Display toggle
|
||||
if arg in ("show", "on"):
|
||||
self.show_reasoning = True
|
||||
if self.agent:
|
||||
self.agent.reasoning_callback = self._on_reasoning
|
||||
_cprint(f" {_GOLD}Reasoning display: ON{_RST}")
|
||||
_cprint(f" {_DIM}Model thinking will be shown during and after each response.{_RST}")
|
||||
return
|
||||
if arg in ("hide", "off"):
|
||||
self.show_reasoning = False
|
||||
if self.agent:
|
||||
self.agent.reasoning_callback = None
|
||||
_cprint(f" {_GOLD}Reasoning display: OFF{_RST}")
|
||||
return
|
||||
|
||||
# Effort level change
|
||||
parsed = _parse_reasoning_config(arg)
|
||||
if parsed is None:
|
||||
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
|
||||
_cprint(f" {_DIM}Valid levels: none, low, minimal, medium, high, xhigh{_RST}")
|
||||
_cprint(f" {_DIM}Display: show, hide{_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" {_GOLD}Reasoning effort set to '{arg}' (saved to config){_RST}")
|
||||
else:
|
||||
_cprint(f" {_GOLD}Reasoning effort set to '{arg}' (session only){_RST}")
|
||||
|
||||
def _on_reasoning(self, reasoning_text: str):
|
||||
"""Callback for intermediate reasoning display during tool-call loops."""
|
||||
lines = reasoning_text.strip().splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5])
|
||||
preview += f"\n ... ({len(lines) - 5} more lines)"
|
||||
else:
|
||||
preview = reasoning_text.strip()
|
||||
_cprint(f" {_DIM}[thinking] {preview}{_RST}")
|
||||
|
||||
def _manual_compress(self):
|
||||
"""Manually trigger context compression on the current conversation."""
|
||||
if not self.conversation_history or len(self.conversation_history) < 4:
|
||||
|
|
@ -3542,6 +3617,24 @@ class HermesCLI:
|
|||
if response and pending_message:
|
||||
response = response + "\n\n---\n_[Interrupted - processing new message]_"
|
||||
|
||||
# Display reasoning (thinking) box if enabled and available
|
||||
if self.show_reasoning and result:
|
||||
reasoning = result.get("last_reasoning")
|
||||
if reasoning:
|
||||
w = shutil.get_terminal_size().columns
|
||||
r_label = " Reasoning "
|
||||
r_fill = w - 2 - len(r_label)
|
||||
r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}"
|
||||
r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}"
|
||||
# Collapse long reasoning: show first 10 lines
|
||||
lines = reasoning.strip().splitlines()
|
||||
if len(lines) > 10:
|
||||
display_reasoning = "\n".join(lines[:10])
|
||||
display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines){_RST}"
|
||||
else:
|
||||
display_reasoning = reasoning.strip()
|
||||
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
|
||||
|
||||
if response:
|
||||
# Use a Rich Panel for the response box — adapts to terminal
|
||||
# width at render time instead of hard-coding border length.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ COMMANDS_BY_CATEGORY = {
|
|||
"/prompt": "View/set custom system prompt",
|
||||
"/personality": "Set a predefined personality",
|
||||
"/verbose": "Cycle tool progress display: off → new → all → verbose",
|
||||
"/reasoning": "Manage reasoning effort and display (usage: /reasoning [level|show|hide])",
|
||||
"/skin": "Show or change the display skin/theme",
|
||||
},
|
||||
"Tools & Skills": {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ DEFAULT_CONFIG = {
|
|||
"personality": "kawaii",
|
||||
"resume_display": "full",
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"skin": "default",
|
||||
},
|
||||
|
||||
|
|
@ -1025,6 +1026,14 @@ def show_config():
|
|||
print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}")
|
||||
print(f" Toolsets: {', '.join(config.get('toolsets', ['all']))}")
|
||||
|
||||
# Display
|
||||
print()
|
||||
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
|
||||
display = config.get('display', {})
|
||||
print(f" Personality: {display.get('personality', 'kawaii')}")
|
||||
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
|
||||
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
|
||||
|
||||
# Terminal
|
||||
print()
|
||||
print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
|
||||
|
|
|
|||
16
run_agent.py
16
run_agent.py
|
|
@ -173,6 +173,7 @@ class AIAgent:
|
|||
session_id: str = None,
|
||||
tool_progress_callback: callable = None,
|
||||
thinking_callback: callable = None,
|
||||
reasoning_callback: callable = None,
|
||||
clarify_callback: callable = None,
|
||||
step_callback: callable = None,
|
||||
max_tokens: int = None,
|
||||
|
|
@ -260,6 +261,7 @@ class AIAgent:
|
|||
|
||||
self.tool_progress_callback = tool_progress_callback
|
||||
self.thinking_callback = thinking_callback
|
||||
self.reasoning_callback = reasoning_callback
|
||||
self.clarify_callback = clarify_callback
|
||||
self.step_callback = step_callback
|
||||
self._last_reported_tool = None # Track for "new tool" mode
|
||||
|
|
@ -2420,6 +2422,12 @@ class AIAgent:
|
|||
preview = reasoning_text[:100] + "..." if len(reasoning_text) > 100 else reasoning_text
|
||||
logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {preview}")
|
||||
|
||||
if reasoning_text and self.reasoning_callback:
|
||||
try:
|
||||
self.reasoning_callback(reasoning_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": assistant_message.content or "",
|
||||
|
|
@ -4470,9 +4478,17 @@ class AIAgent:
|
|||
if final_response and not interrupted:
|
||||
self._honcho_sync(original_user_message, final_response)
|
||||
|
||||
# Extract reasoning from the last assistant message (if any)
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
||||
# Build result with interrupt info if applicable
|
||||
result = {
|
||||
"final_response": final_response,
|
||||
"last_reasoning": last_reasoning,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": completed,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ EXPECTED_COMMANDS = {
|
|||
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
|
||||
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
|
||||
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
|
||||
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||
"/verbose", "/reasoning", "/compress", "/title", "/usage", "/insights", "/paste",
|
||||
"/reload-mcp", "/rollback", "/background", "/skin", "/quit",
|
||||
}
|
||||
|
||||
|
|
|
|||
422
tests/test_reasoning_command.py
Normal file
422
tests/test_reasoning_command.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"""Tests for the combined /reasoning command.
|
||||
|
||||
Covers both reasoning effort level management and reasoning display toggle,
|
||||
plus the reasoning extraction and display pipeline from run_agent through CLI.
|
||||
|
||||
Combines functionality from:
|
||||
- PR #789 (Aum08Desai): reasoning effort level management
|
||||
- PR #790 (0xbyt4): reasoning display toggle and rendering
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Effort level parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseReasoningConfig(unittest.TestCase):
|
||||
"""Verify _parse_reasoning_config handles all effort levels."""
|
||||
|
||||
def _parse(self, effort):
|
||||
from cli import _parse_reasoning_config
|
||||
return _parse_reasoning_config(effort)
|
||||
|
||||
def test_none_disables(self):
|
||||
result = self._parse("none")
|
||||
self.assertEqual(result, {"enabled": False})
|
||||
|
||||
def test_valid_levels(self):
|
||||
for level in ("low", "medium", "high", "xhigh", "minimal"):
|
||||
result = self._parse(level)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertTrue(result.get("enabled"))
|
||||
self.assertEqual(result["effort"], level)
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
self.assertIsNone(self._parse(""))
|
||||
self.assertIsNone(self._parse(" "))
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
self.assertIsNone(self._parse("ultra"))
|
||||
self.assertIsNone(self._parse("turbo"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
result = self._parse("HIGH")
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["effort"], "high")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /reasoning command handler (combined effort + display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHandleReasoningCommand(unittest.TestCase):
|
||||
"""Test the combined _handle_reasoning_command method."""
|
||||
|
||||
def _make_cli(self, reasoning_config=None, show_reasoning=False):
|
||||
"""Create a minimal CLI stub with the reasoning attributes."""
|
||||
stub = SimpleNamespace(
|
||||
reasoning_config=reasoning_config,
|
||||
show_reasoning=show_reasoning,
|
||||
agent=MagicMock(),
|
||||
)
|
||||
return stub
|
||||
|
||||
def test_show_enables_display(self):
|
||||
stub = self._make_cli(show_reasoning=False)
|
||||
# Simulate /reasoning show
|
||||
arg = "show"
|
||||
if arg in ("show", "on"):
|
||||
stub.show_reasoning = True
|
||||
stub.agent.reasoning_callback = lambda x: None
|
||||
self.assertTrue(stub.show_reasoning)
|
||||
|
||||
def test_hide_disables_display(self):
|
||||
stub = self._make_cli(show_reasoning=True)
|
||||
# Simulate /reasoning hide
|
||||
arg = "hide"
|
||||
if arg in ("hide", "off"):
|
||||
stub.show_reasoning = False
|
||||
stub.agent.reasoning_callback = None
|
||||
self.assertFalse(stub.show_reasoning)
|
||||
self.assertIsNone(stub.agent.reasoning_callback)
|
||||
|
||||
def test_on_enables_display(self):
|
||||
stub = self._make_cli(show_reasoning=False)
|
||||
arg = "on"
|
||||
if arg in ("show", "on"):
|
||||
stub.show_reasoning = True
|
||||
self.assertTrue(stub.show_reasoning)
|
||||
|
||||
def test_off_disables_display(self):
|
||||
stub = self._make_cli(show_reasoning=True)
|
||||
arg = "off"
|
||||
if arg in ("hide", "off"):
|
||||
stub.show_reasoning = False
|
||||
self.assertFalse(stub.show_reasoning)
|
||||
|
||||
def test_effort_level_sets_config(self):
|
||||
"""Setting an effort level should update reasoning_config."""
|
||||
from cli import _parse_reasoning_config
|
||||
stub = self._make_cli()
|
||||
arg = "high"
|
||||
parsed = _parse_reasoning_config(arg)
|
||||
stub.reasoning_config = parsed
|
||||
self.assertEqual(stub.reasoning_config, {"enabled": True, "effort": "high"})
|
||||
|
||||
def test_effort_none_disables_reasoning(self):
|
||||
from cli import _parse_reasoning_config
|
||||
stub = self._make_cli()
|
||||
parsed = _parse_reasoning_config("none")
|
||||
stub.reasoning_config = parsed
|
||||
self.assertEqual(stub.reasoning_config, {"enabled": False})
|
||||
|
||||
def test_invalid_argument_rejected(self):
|
||||
"""Invalid arguments should be rejected (parsed returns None)."""
|
||||
from cli import _parse_reasoning_config
|
||||
parsed = _parse_reasoning_config("turbo")
|
||||
self.assertIsNone(parsed)
|
||||
|
||||
def test_no_args_shows_status(self):
|
||||
"""With no args, should show current state (no crash)."""
|
||||
stub = self._make_cli(reasoning_config=None, show_reasoning=False)
|
||||
rc = stub.reasoning_config
|
||||
if rc is None:
|
||||
level = "medium (default)"
|
||||
elif rc.get("enabled") is False:
|
||||
level = "none (disabled)"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
display_state = "on" if stub.show_reasoning else "off"
|
||||
self.assertEqual(level, "medium (default)")
|
||||
self.assertEqual(display_state, "off")
|
||||
|
||||
def test_status_with_disabled_reasoning(self):
|
||||
stub = self._make_cli(reasoning_config={"enabled": False}, show_reasoning=True)
|
||||
rc = stub.reasoning_config
|
||||
if rc is None:
|
||||
level = "medium (default)"
|
||||
elif rc.get("enabled") is False:
|
||||
level = "none (disabled)"
|
||||
else:
|
||||
level = rc.get("effort", "medium")
|
||||
self.assertEqual(level, "none (disabled)")
|
||||
|
||||
def test_status_with_explicit_level(self):
|
||||
stub = self._make_cli(
|
||||
reasoning_config={"enabled": True, "effort": "xhigh"},
|
||||
show_reasoning=True,
|
||||
)
|
||||
rc = stub.reasoning_config
|
||||
level = rc.get("effort", "medium")
|
||||
self.assertEqual(level, "xhigh")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning extraction and result dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLastReasoningInResult(unittest.TestCase):
|
||||
"""Verify reasoning extraction from the messages list."""
|
||||
|
||||
def _build_messages(self, reasoning=None):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!",
|
||||
"reasoning": reasoning,
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
]
|
||||
|
||||
def test_reasoning_present(self):
|
||||
messages = self._build_messages(reasoning="Let me think...")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertEqual(last_reasoning, "Let me think...")
|
||||
|
||||
def test_reasoning_none(self):
|
||||
messages = self._build_messages(reasoning=None)
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertIsNone(last_reasoning)
|
||||
|
||||
def test_picks_last_assistant(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "...", "reasoning": "first thought"},
|
||||
{"role": "tool", "content": "result"},
|
||||
{"role": "assistant", "content": "done!", "reasoning": "final thought"},
|
||||
]
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertEqual(last_reasoning, "final thought")
|
||||
|
||||
def test_empty_reasoning_treated_as_none(self):
|
||||
messages = self._build_messages(reasoning="")
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
self.assertIsNone(last_reasoning)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning display collapse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReasoningCollapse(unittest.TestCase):
|
||||
"""Verify long reasoning is collapsed to 10 lines in the box."""
|
||||
|
||||
def test_short_reasoning_not_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(5))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertLessEqual(len(lines), 10)
|
||||
|
||||
def test_long_reasoning_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(25))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertTrue(len(lines) > 10)
|
||||
if len(lines) > 10:
|
||||
display = "\n".join(lines[:10])
|
||||
display += f"\n ... ({len(lines) - 10} more lines)"
|
||||
display_lines = display.splitlines()
|
||||
self.assertEqual(len(display_lines), 11)
|
||||
self.assertIn("15 more lines", display_lines[-1])
|
||||
|
||||
def test_exactly_10_lines_not_collapsed(self):
|
||||
reasoning = "\n".join(f"Line {i}" for i in range(10))
|
||||
lines = reasoning.strip().splitlines()
|
||||
self.assertEqual(len(lines), 10)
|
||||
self.assertFalse(len(lines) > 10)
|
||||
|
||||
def test_intermediate_callback_collapses_to_5(self):
|
||||
"""_on_reasoning shows max 5 lines."""
|
||||
reasoning = "\n".join(f"Step {i}" for i in range(12))
|
||||
lines = reasoning.strip().splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5])
|
||||
preview += f"\n ... ({len(lines) - 5} more lines)"
|
||||
else:
|
||||
preview = reasoning.strip()
|
||||
preview_lines = preview.splitlines()
|
||||
self.assertEqual(len(preview_lines), 6)
|
||||
self.assertIn("7 more lines", preview_lines[-1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reasoning callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReasoningCallback(unittest.TestCase):
|
||||
"""Verify reasoning_callback invocation."""
|
||||
|
||||
def test_callback_invoked_with_reasoning(self):
|
||||
captured = []
|
||||
agent = MagicMock()
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent._extract_reasoning = MagicMock(return_value="deep thought")
|
||||
|
||||
reasoning_text = agent._extract_reasoning(MagicMock())
|
||||
if reasoning_text and agent.reasoning_callback:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
self.assertEqual(captured, ["deep thought"])
|
||||
|
||||
def test_callback_not_invoked_without_reasoning(self):
|
||||
captured = []
|
||||
agent = MagicMock()
|
||||
agent.reasoning_callback = lambda t: captured.append(t)
|
||||
agent._extract_reasoning = MagicMock(return_value=None)
|
||||
|
||||
reasoning_text = agent._extract_reasoning(MagicMock())
|
||||
if reasoning_text and agent.reasoning_callback:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
self.assertEqual(captured, [])
|
||||
|
||||
def test_callback_none_does_not_crash(self):
|
||||
reasoning_text = "some thought"
|
||||
callback = None
|
||||
if reasoning_text and callback:
|
||||
callback(reasoning_text)
|
||||
# No exception = pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real provider format extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractReasoningFormats(unittest.TestCase):
|
||||
"""Test _extract_reasoning with real provider response formats."""
|
||||
|
||||
def _get_extractor(self):
|
||||
from run_agent import AIAgent
|
||||
return AIAgent._extract_reasoning
|
||||
|
||||
def test_openrouter_reasoning_details(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=[
|
||||
{"type": "reasoning.summary", "summary": "Analyzing Python lists."},
|
||||
],
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("Python lists", result)
|
||||
|
||||
def test_deepseek_reasoning_field(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning="Solving step by step.\nx + y = 8.",
|
||||
reasoning_content=None,
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("x + y = 8", result)
|
||||
|
||||
def test_moonshot_reasoning_content(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(
|
||||
reasoning_content="Explaining async/await.",
|
||||
)
|
||||
result = extract(None, msg)
|
||||
self.assertIn("async/await", result)
|
||||
|
||||
def test_no_reasoning_returns_none(self):
|
||||
extract = self._get_extractor()
|
||||
msg = SimpleNamespace(content="Hello!")
|
||||
result = extract(None, msg)
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConfigDefault(unittest.TestCase):
|
||||
"""Verify config default for show_reasoning."""
|
||||
|
||||
def test_default_config_has_show_reasoning(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
display = DEFAULT_CONFIG.get("display", {})
|
||||
self.assertIn("show_reasoning", display)
|
||||
self.assertFalse(display["show_reasoning"])
|
||||
|
||||
|
||||
class TestCommandRegistered(unittest.TestCase):
|
||||
"""Verify /reasoning is in the COMMANDS dict."""
|
||||
|
||||
def test_reasoning_in_commands(self):
|
||||
from hermes_cli.commands import COMMANDS
|
||||
self.assertIn("/reasoning", COMMANDS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEndToEndPipeline(unittest.TestCase):
|
||||
"""Simulate the full pipeline: extraction -> result dict -> display."""
|
||||
|
||||
def test_openrouter_claude_pipeline(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
api_message = SimpleNamespace(
|
||||
role="assistant",
|
||||
content="Lists support append().",
|
||||
tool_calls=None,
|
||||
reasoning=None,
|
||||
reasoning_content=None,
|
||||
reasoning_details=[
|
||||
{"type": "reasoning.summary", "summary": "Python list methods."},
|
||||
],
|
||||
)
|
||||
|
||||
reasoning = AIAgent._extract_reasoning(None, api_message)
|
||||
self.assertIsNotNone(reasoning)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "How do I add items?"},
|
||||
{"role": "assistant", "content": api_message.content, "reasoning": reasoning},
|
||||
]
|
||||
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
||||
result = {
|
||||
"final_response": api_message.content,
|
||||
"last_reasoning": last_reasoning,
|
||||
}
|
||||
|
||||
self.assertIn("last_reasoning", result)
|
||||
self.assertIn("Python list methods", result["last_reasoning"])
|
||||
|
||||
def test_no_reasoning_model_pipeline(self):
|
||||
from run_agent import AIAgent
|
||||
|
||||
api_message = SimpleNamespace(content="Paris.", tool_calls=None)
|
||||
reasoning = AIAgent._extract_reasoning(None, api_message)
|
||||
self.assertIsNone(reasoning)
|
||||
|
||||
result = {"final_response": api_message.content, "last_reasoning": reasoning}
|
||||
self.assertIsNone(result["last_reasoning"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -147,6 +147,7 @@ Type `/` in the interactive CLI to see an autocomplete dropdown.
|
|||
| `/config` | Show current configuration |
|
||||
| `/prompt [text]` | View/set custom system prompt |
|
||||
| `/personality [name]` | Set a predefined personality |
|
||||
| `/reasoning [arg]` | Manage reasoning effort and display. Args: effort level (`none`, `low`, `medium`, `high`, `xhigh`) or display toggle (`show`, `hide`). No args shows current state. |
|
||||
|
||||
### Conversation
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ Type `/` to see an autocomplete dropdown of all available commands.
|
|||
| `/config` | Show current configuration |
|
||||
| `/prompt [text]` | View/set/clear custom system prompt |
|
||||
| `/personality [name]` | Set a predefined personality |
|
||||
| `/reasoning [arg]` | Manage reasoning effort (`none`/`low`/`medium`/`high`/`xhigh`) and display (`show`/`hide`) |
|
||||
|
||||
### Conversation Management
|
||||
|
||||
|
|
|
|||
|
|
@ -608,6 +608,16 @@ agent:
|
|||
|
||||
When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency.
|
||||
|
||||
You can also change the reasoning effort at runtime with the `/reasoning` command:
|
||||
|
||||
```
|
||||
/reasoning # Show current effort level and display state
|
||||
/reasoning high # Set reasoning effort to high
|
||||
/reasoning none # Disable reasoning
|
||||
/reasoning show # Show model thinking above each response
|
||||
/reasoning hide # Hide model thinking
|
||||
```
|
||||
|
||||
## TTS Configuration
|
||||
|
||||
```yaml
|
||||
|
|
@ -632,6 +642,7 @@ display:
|
|||
compact: false # Compact output mode (less whitespace)
|
||||
resume_display: full # full (show previous messages on resume) | minimal (one-liner only)
|
||||
bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks)
|
||||
show_reasoning: false # Show model reasoning/thinking above each response (toggle with /reasoning show|hide)
|
||||
```
|
||||
|
||||
| Mode | What you see |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue