feat(cli): configurable busy input mode + fix /queue always working (#3298)
Two changes:
1. Fix /queue command: remove the _agent_running guard that rejected
/queue after the agent finished. The prompt was deferred in
_pending_input until the agent completed, then the handler checked
_agent_running (now False) and rejected it. /queue now always queues
regardless of timing.
2. Add display.busy_input_mode config (CLI-only):
- 'interrupt' (default): Enter while busy interrupts the current run
(preserves existing behavior)
- 'queue': Enter while busy queues the message for the next turn,
with a 'Queued for the next turn: ...' confirmation
Ctrl+C always interrupts regardless of this setting.
Salvaged from PR #3037 by StefanoChiodino. Key differences:
- Default is 'interrupt' (preserves existing behavior) not 'queue'
- No config version bump (unnecessary for new key in existing section)
- Simpler normalization (no alias map)
- /queue fix is simpler: just remove the guard instead of intercepting
commands during busy state
This commit is contained in:
@@ -688,6 +688,12 @@ display:
|
|||||||
# Toggle at runtime with /verbose in the CLI
|
# Toggle at runtime with /verbose in the CLI
|
||||||
tool_progress: all
|
tool_progress: all
|
||||||
|
|
||||||
|
# What Enter does when Hermes is already busy in the CLI.
|
||||||
|
# interrupt: Interrupt the current run and redirect Hermes (default)
|
||||||
|
# queue: Queue your message for the next turn
|
||||||
|
# Ctrl+C always interrupts regardless of this setting.
|
||||||
|
busy_input_mode: interrupt
|
||||||
|
|
||||||
# Background process notifications (gateway/messaging only).
|
# Background process notifications (gateway/messaging only).
|
||||||
# Controls how chatty the process watcher is when you use
|
# Controls how chatty the process watcher is when you use
|
||||||
# terminal(background=true, check_interval=...) from Telegram/Discord/etc.
|
# terminal(background=true, check_interval=...) from Telegram/Discord/etc.
|
||||||
|
|||||||
48
cli.py
48
cli.py
@@ -205,6 +205,7 @@ def load_cli_config() -> Dict[str, Any]:
|
|||||||
"resume_display": "full",
|
"resume_display": "full",
|
||||||
"show_reasoning": False,
|
"show_reasoning": False,
|
||||||
"streaming": True,
|
"streaming": True,
|
||||||
|
"busy_input_mode": "interrupt",
|
||||||
|
|
||||||
"skin": "default",
|
"skin": "default",
|
||||||
},
|
},
|
||||||
@@ -1042,6 +1043,9 @@ class HermesCLI:
|
|||||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||||
# show_reasoning: display model thinking/reasoning before the response
|
# show_reasoning: display model thinking/reasoning before the response
|
||||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||||
|
# busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
|
||||||
|
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
|
||||||
|
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
|
||||||
|
|
||||||
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
|
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
|
||||||
|
|
||||||
@@ -3736,17 +3740,17 @@ class HermesCLI:
|
|||||||
elif canonical == "background":
|
elif canonical == "background":
|
||||||
self._handle_background_command(cmd_original)
|
self._handle_background_command(cmd_original)
|
||||||
elif canonical == "queue":
|
elif canonical == "queue":
|
||||||
if not self._agent_running:
|
# Extract prompt after "/queue " or "/q "
|
||||||
_cprint(" /queue only works while Hermes is busy. Just type your message normally.")
|
parts = cmd_original.split(None, 1)
|
||||||
|
payload = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
if not payload:
|
||||||
|
_cprint(" Usage: /queue <prompt>")
|
||||||
else:
|
else:
|
||||||
# Extract prompt after "/queue " or "/q "
|
self._pending_input.put(payload)
|
||||||
parts = cmd_original.split(None, 1)
|
if self._agent_running:
|
||||||
payload = parts[1].strip() if len(parts) > 1 else ""
|
|
||||||
if not payload:
|
|
||||||
_cprint(" Usage: /queue <prompt>")
|
|
||||||
else:
|
|
||||||
self._pending_input.put(payload)
|
|
||||||
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
_cprint(f" Queued for the next turn: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||||
|
else:
|
||||||
|
_cprint(f" Queued: {payload[:80]}{'...' if len(payload) > 80 else ''}")
|
||||||
elif canonical == "skin":
|
elif canonical == "skin":
|
||||||
self._handle_skin_command(cmd_original)
|
self._handle_skin_command(cmd_original)
|
||||||
elif canonical == "voice":
|
elif canonical == "voice":
|
||||||
@@ -6126,16 +6130,22 @@ class HermesCLI:
|
|||||||
# Bundle text + images as a tuple when images are present
|
# Bundle text + images as a tuple when images are present
|
||||||
payload = (text, images) if images else text
|
payload = (text, images) if images else text
|
||||||
if self._agent_running and not (text and text.startswith("/")):
|
if self._agent_running and not (text and text.startswith("/")):
|
||||||
self._interrupt_queue.put(payload)
|
if self.busy_input_mode == "queue":
|
||||||
# Debug: log to file when message enters interrupt queue
|
# Queue for the next turn instead of interrupting
|
||||||
try:
|
self._pending_input.put(payload)
|
||||||
_dbg = _hermes_home / "interrupt_debug.log"
|
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
|
||||||
with open(_dbg, "a") as _f:
|
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
|
||||||
import time as _t
|
else:
|
||||||
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
|
self._interrupt_queue.put(payload)
|
||||||
f"agent_running={self._agent_running}\n")
|
# Debug: log to file when message enters interrupt queue
|
||||||
except Exception:
|
try:
|
||||||
pass
|
_dbg = _hermes_home / "interrupt_debug.log"
|
||||||
|
with open(_dbg, "a") as _f:
|
||||||
|
import time as _t
|
||||||
|
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
|
||||||
|
f"agent_running={self._agent_running}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
self._pending_input.put(payload)
|
self._pending_input.put(payload)
|
||||||
event.app.current_buffer.reset(append_to_history=True)
|
event.app.current_buffer.reset(append_to_history=True)
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ DEFAULT_CONFIG = {
|
|||||||
"compact": False,
|
"compact": False,
|
||||||
"personality": "kawaii",
|
"personality": "kawaii",
|
||||||
"resume_display": "full",
|
"resume_display": "full",
|
||||||
|
"busy_input_mode": "interrupt",
|
||||||
"bell_on_complete": False,
|
"bell_on_complete": False,
|
||||||
"show_reasoning": False,
|
"show_reasoning": False,
|
||||||
"streaming": False,
|
"streaming": False,
|
||||||
|
|||||||
@@ -96,6 +96,59 @@ class TestVerboseAndToolProgress:
|
|||||||
assert cli.tool_progress_mode in ("off", "new", "all", "verbose")
|
assert cli.tool_progress_mode in ("off", "new", "all", "verbose")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBusyInputMode:
|
||||||
|
def test_default_busy_input_mode_is_interrupt(self):
|
||||||
|
cli = _make_cli()
|
||||||
|
assert cli.busy_input_mode == "interrupt"
|
||||||
|
|
||||||
|
def test_busy_input_mode_queue_is_honored(self):
|
||||||
|
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
|
||||||
|
assert cli.busy_input_mode == "queue"
|
||||||
|
|
||||||
|
def test_unknown_busy_input_mode_falls_back_to_interrupt(self):
|
||||||
|
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "bogus"}})
|
||||||
|
assert cli.busy_input_mode == "interrupt"
|
||||||
|
|
||||||
|
def test_queue_command_works_while_busy(self):
|
||||||
|
"""When agent is running, /queue should still put the prompt in _pending_input."""
|
||||||
|
cli = _make_cli()
|
||||||
|
cli._agent_running = True
|
||||||
|
cli.process_command("/queue follow up")
|
||||||
|
assert cli._pending_input.get_nowait() == "follow up"
|
||||||
|
|
||||||
|
def test_queue_command_works_while_idle(self):
|
||||||
|
"""When agent is idle, /queue should still queue (not reject)."""
|
||||||
|
cli = _make_cli()
|
||||||
|
cli._agent_running = False
|
||||||
|
cli.process_command("/queue follow up")
|
||||||
|
assert cli._pending_input.get_nowait() == "follow up"
|
||||||
|
|
||||||
|
def test_queue_mode_routes_busy_enter_to_pending(self):
|
||||||
|
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
|
||||||
|
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})
|
||||||
|
cli._agent_running = True
|
||||||
|
# Simulate what handle_enter does for non-command input while busy
|
||||||
|
text = "follow up"
|
||||||
|
if cli.busy_input_mode == "queue":
|
||||||
|
cli._pending_input.put(text)
|
||||||
|
else:
|
||||||
|
cli._interrupt_queue.put(text)
|
||||||
|
assert cli._pending_input.get_nowait() == "follow up"
|
||||||
|
assert cli._interrupt_queue.empty()
|
||||||
|
|
||||||
|
def test_interrupt_mode_routes_busy_enter_to_interrupt(self):
|
||||||
|
"""In interrupt mode (default), Enter while busy goes to _interrupt_queue."""
|
||||||
|
cli = _make_cli()
|
||||||
|
cli._agent_running = True
|
||||||
|
text = "redirect"
|
||||||
|
if cli.busy_input_mode == "queue":
|
||||||
|
cli._pending_input.put(text)
|
||||||
|
else:
|
||||||
|
cli._interrupt_queue.put(text)
|
||||||
|
assert cli._interrupt_queue.get_nowait() == "redirect"
|
||||||
|
assert cli._pending_input.empty()
|
||||||
|
|
||||||
|
|
||||||
class TestSingleQueryState:
|
class TestSingleQueryState:
|
||||||
def test_voice_and_interrupt_state_initialized_before_run(self):
|
def test_voice_and_interrupt_state_initialized_before_run(self):
|
||||||
"""Single-query mode calls chat() without going through run()."""
|
"""Single-query mode calls chat() without going through run()."""
|
||||||
|
|||||||
Reference in New Issue
Block a user