Merge feature/background-command: add /background slash command

Adds /background <prompt> command to both CLI and gateway platforms.
Spawns a new agent session in the background — users can keep chatting
while the task runs, and results are delivered when done.

CLI: threaded execution with rich Panel output
Gateway: asyncio task with platform adapter delivery (text, images, media)

Includes 15 new tests and updates to command registry.
This commit is contained in:
teknium1
2026-03-11 02:46:37 -07:00
5 changed files with 628 additions and 2 deletions

113
cli.py
View File

@@ -1244,6 +1244,10 @@ class HermesCLI:
self._command_running = False
self._command_status = ""
# Background task tracking: {task_id: threading.Thread}
self._background_tasks: Dict[str, threading.Thread] = {}
self._background_task_counter = 0
def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
import time as _time
@@ -2825,6 +2829,8 @@ class HermesCLI:
self._reload_mcp()
elif cmd_lower.startswith("/rollback"):
self._handle_rollback_command(cmd_original)
elif cmd_lower.startswith("/background"):
self._handle_background_command(cmd_original)
elif cmd_lower.startswith("/skin"):
self._handle_skin_command(cmd_original)
else:
@@ -2869,6 +2875,113 @@ class HermesCLI:
return True
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
Spawns a new AIAgent in a background thread with its own session.
When it completes, prints the result to the CLI without modifying
the active session's conversation history.
"""
parts = cmd.strip().split(maxsplit=1)
if len(parts) < 2 or not parts[1].strip():
_cprint(" Usage: /background <prompt>")
_cprint(" Example: /background Summarize the top HN stories today")
_cprint(" The task runs in a separate session and results display here when done.")
return
prompt = parts[1].strip()
self._background_task_counter += 1
task_num = self._background_task_counter
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Make sure we have valid credentials
if not self._ensure_runtime_credentials():
_cprint(" (>_<) Cannot start background task: no valid credentials.")
return
_cprint(f" 🔄 Background task #{task_num} started: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
_cprint(f" Task ID: {task_id}")
_cprint(f" You can continue chatting — results will appear when done.\n")
def run_background():
try:
bg_agent = AIAgent(
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
provider=self.provider,
api_mode=self.api_mode,
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
quiet_mode=True,
verbose_logging=False,
session_id=task_id,
platform="cli",
session_db=self._session_db,
reasoning_config=self.reasoning_config,
providers_allowed=self._providers_only,
providers_ignored=self._providers_ignore,
providers_order=self._providers_order,
provider_sort=self._provider_sort,
provider_require_parameters=self._provider_require_params,
provider_data_collection=self._provider_data_collection,
fallback_model=self._fallback_model,
)
result = bg_agent.run_conversation(
user_message=prompt,
task_id=task_id,
)
response = result.get("final_response", "") if result else ""
if not response and result and result.get("error"):
response = f"Error: {result['error']}"
# Display result in the CLI (thread-safe via patch_stdout)
print()
_cprint(f"{_GOLD}{'' * 40}{_RST}")
_cprint(f" ✅ Background task #{task_num} complete")
_cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"")
_cprint(f"{_GOLD}{'' * 40}{_RST}")
if response:
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
label = _skin.get_branding("response_label", "⚕ Hermes")
_resp_color = _skin.get_color("response_border", "#CD7F32")
except Exception:
label = "⚕ Hermes"
_resp_color = "#CD7F32"
_chat_console = ChatConsole()
_chat_console.print(Panel(
response,
title=f"[bold]{label} (background #{task_num})[/bold]",
title_align="left",
border_style=_resp_color,
box=rich_box.HORIZONTALS,
padding=(1, 2),
))
else:
_cprint(" (No response generated)")
# Play bell if enabled
if self.bell_on_complete:
sys.stdout.write("\a")
sys.stdout.flush()
except Exception as e:
print()
_cprint(f" ❌ Background task #{task_num} failed: {e}")
finally:
self._background_tasks.pop(task_id, None)
if self._app:
self._invalidate(min_interval=0)
thread = threading.Thread(target=run_background, daemon=True, name=f"bg-task-{task_id}")
self._background_tasks[task_id] = thread
thread.start()
def _handle_skin_command(self, cmd: str):
"""Handle /skin [name] — show or change the display skin."""
try:

View File

@@ -806,7 +806,8 @@ class GatewayRunner:
_known_commands = {"new", "reset", "help", "status", "stop", "model",
"personality", "retry", "undo", "sethome", "set-home",
"compress", "usage", "insights", "reload-mcp", "reload_mcp",
"update", "title", "resume", "provider", "rollback"}
"update", "title", "resume", "provider", "rollback",
"background"}
if command and command in _known_commands:
await self.hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
@@ -868,6 +869,9 @@ class GatewayRunner:
if command == "rollback":
return await self._handle_rollback_command(event)
if command == "background":
return await self._handle_background_command(event)
# User-defined quick commands (bypass agent loop, no LLM call)
if command:
@@ -1495,6 +1499,7 @@ class GatewayRunner:
"`/usage` — Show token usage for this session",
"`/insights [days]` — Show usage insights and analytics",
"`/rollback [number]` — List or restore filesystem checkpoints",
"`/background <prompt>` — Run a prompt in a separate background session",
"`/reload-mcp` — Reload MCP servers from config",
"`/update` — Update Hermes Agent to the latest version",
"`/help` — Show this message",
@@ -1904,6 +1909,208 @@ class GatewayRunner:
)
return f"{result['error']}"
async def _handle_background_command(self, event: MessageEvent) -> str:
"""Handle /background <prompt> — run a prompt in a separate background session.
Spawns a new AIAgent in a background thread with its own session.
When it completes, sends the result back to the same chat without
modifying the active session's conversation history.
"""
prompt = event.get_command_args().strip()
if not prompt:
return (
"Usage: /background <prompt>\n"
"Example: /background Summarize the top HN stories today\n\n"
"Runs the prompt in a separate session. "
"You can keep chatting — the result will appear here when done."
)
source = event.source
task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}"
# Fire-and-forget the background task
asyncio.create_task(
self._run_background_task(prompt, source, task_id)
)
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
return f'🔄 Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done.'
async def _run_background_task(
self, prompt: str, source: "SessionSource", task_id: str
) -> None:
"""Execute a background agent task and deliver the result to the chat."""
from run_agent import AIAgent
adapter = self.adapters.get(source.platform)
if not adapter:
logger.warning("No adapter for platform %s in background task %s", source.platform, task_id)
return
_thread_metadata = {"thread_id": source.thread_id} if source.thread_id else None
try:
runtime_kwargs = _resolve_runtime_agent_kwargs()
if not runtime_kwargs.get("api_key"):
await adapter.send(
source.chat_id,
f"❌ Background task {task_id} failed: no provider credentials configured.",
metadata=_thread_metadata,
)
return
# Read model from config (same as _run_agent)
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
try:
import yaml as _y
_cfg_path = _hermes_home / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_cfg = _y.safe_load(_f) or {}
_model_cfg = _cfg.get("model", {})
if isinstance(_model_cfg, str):
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
except Exception:
pass
# Determine toolset (same logic as _run_agent)
default_toolset_map = {
Platform.LOCAL: "hermes-cli",
Platform.TELEGRAM: "hermes-telegram",
Platform.DISCORD: "hermes-discord",
Platform.WHATSAPP: "hermes-whatsapp",
Platform.SLACK: "hermes-slack",
Platform.SIGNAL: "hermes-signal",
Platform.HOMEASSISTANT: "hermes-homeassistant",
}
platform_toolsets_config = {}
try:
config_path = _hermes_home / 'config.yaml'
if config_path.exists():
import yaml
with open(config_path, 'r', encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
platform_toolsets_config = user_config.get("platform_toolsets", {})
except Exception:
pass
platform_config_key = {
Platform.LOCAL: "cli",
Platform.TELEGRAM: "telegram",
Platform.DISCORD: "discord",
Platform.WHATSAPP: "whatsapp",
Platform.SLACK: "slack",
Platform.SIGNAL: "signal",
Platform.HOMEASSISTANT: "homeassistant",
}.get(source.platform, "telegram")
config_toolsets = platform_toolsets_config.get(platform_config_key)
if config_toolsets and isinstance(config_toolsets, list):
enabled_toolsets = config_toolsets
else:
default_toolset = default_toolset_map.get(source.platform, "hermes-telegram")
enabled_toolsets = [default_toolset]
platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value
pr = self._provider_routing
max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90"))
def run_sync():
agent = AIAgent(
model=model,
**runtime_kwargs,
max_iterations=max_iterations,
quiet_mode=True,
verbose_logging=False,
enabled_toolsets=enabled_toolsets,
reasoning_config=self._reasoning_config,
providers_allowed=pr.get("only"),
providers_ignored=pr.get("ignore"),
providers_order=pr.get("order"),
provider_sort=pr.get("sort"),
provider_require_parameters=pr.get("require_parameters", False),
provider_data_collection=pr.get("data_collection"),
session_id=task_id,
platform=platform_key,
session_db=self._session_db,
fallback_model=self._fallback_model,
)
return agent.run_conversation(
user_message=prompt,
task_id=task_id,
)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, run_sync)
response = result.get("final_response", "") if result else ""
if not response and result and result.get("error"):
response = f"Error: {result['error']}"
# Extract media files from the response
if response:
media_files, response = adapter.extract_media(response)
images, text_content = adapter.extract_images(response)
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
header = f'✅ Background task complete\nPrompt: "{preview}"\n\n'
if text_content:
await adapter.send(
chat_id=source.chat_id,
content=header + text_content,
metadata=_thread_metadata,
)
elif not images and not media_files:
await adapter.send(
chat_id=source.chat_id,
content=header + "(No response generated)",
metadata=_thread_metadata,
)
# Send extracted images
for image_url, alt_text in (images or []):
try:
await adapter.send_image(
chat_id=source.chat_id,
image_url=image_url,
caption=alt_text,
)
except Exception:
pass
# Send media files
for media_path in (media_files or []):
try:
await adapter.send_file(
chat_id=source.chat_id,
file_path=media_path,
)
except Exception:
pass
else:
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
await adapter.send(
chat_id=source.chat_id,
content=f'✅ Background task complete\nPrompt: "{preview}"\n\n(No response generated)',
metadata=_thread_metadata,
)
except Exception as e:
logger.exception("Background task %s failed", task_id)
try:
await adapter.send(
chat_id=source.chat_id,
content=f"❌ Background task {task_id} failed: {e}",
metadata=_thread_metadata,
)
except Exception:
pass
async def _handle_compress_command(self, event: MessageEvent) -> str:
"""Handle /compress command -- manually compress conversation context."""
source = event.source

View File

@@ -26,6 +26,7 @@ COMMANDS_BY_CATEGORY = {
"/title": "Set a title for the current session (usage: /title My Session Name)",
"/compress": "Manually compress conversation context (flush memories + summarize)",
"/rollback": "List or restore filesystem checkpoints (usage: /rollback [number])",
"/background": "Run a prompt in the background (usage: /background <prompt>)",
},
"Configuration": {
"/config": "Show current configuration",

View File

@@ -0,0 +1,305 @@
"""Tests for /background gateway slash command.
Tests the _handle_background_command handler (run a prompt in a separate
background session) across gateway messenger platforms.
"""
import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text="/background", platform=Platform.TELEGRAM,
user_id="12345", chat_id="67890"):
"""Build a MessageEvent for testing."""
source = SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="testuser",
)
return MessageEvent(text=text, source=source)
def _make_runner():
"""Create a bare GatewayRunner with minimal mocks."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._running_agents = {}
mock_store = MagicMock()
runner.session_store = mock_store
from gateway.hooks import HookRegistry
runner.hooks = HookRegistry()
return runner
# ---------------------------------------------------------------------------
# _handle_background_command
# ---------------------------------------------------------------------------
class TestHandleBackgroundCommand:
"""Tests for GatewayRunner._handle_background_command."""
@pytest.mark.asyncio
async def test_no_prompt_shows_usage(self):
"""Running /background with no prompt shows usage."""
runner = _make_runner()
event = _make_event(text="/background")
result = await runner._handle_background_command(event)
assert "Usage:" in result
assert "/background" in result
@pytest.mark.asyncio
async def test_empty_prompt_shows_usage(self):
"""Running /background with only whitespace shows usage."""
runner = _make_runner()
event = _make_event(text="/background ")
result = await runner._handle_background_command(event)
assert "Usage:" in result
@pytest.mark.asyncio
async def test_valid_prompt_starts_task(self):
"""Running /background with a prompt returns confirmation and starts task."""
runner = _make_runner()
# Patch asyncio.create_task to capture the coroutine
created_tasks = []
original_create_task = asyncio.create_task
def capture_task(coro, *args, **kwargs):
# Close the coroutine to avoid warnings
coro.close()
mock_task = MagicMock()
created_tasks.append(mock_task)
return mock_task
with patch("gateway.run.asyncio.create_task", side_effect=capture_task):
event = _make_event(text="/background Summarize the top HN stories")
result = await runner._handle_background_command(event)
assert "🔄" in result
assert "Background task started" in result
assert "bg_" in result # task ID starts with bg_
assert "Summarize the top HN stories" in result
assert len(created_tasks) == 1 # background task was created
@pytest.mark.asyncio
async def test_prompt_truncated_in_preview(self):
"""Long prompts are truncated to 60 chars in the confirmation message."""
runner = _make_runner()
long_prompt = "A" * 100
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
event = _make_event(text=f"/background {long_prompt}")
result = await runner._handle_background_command(event)
assert "..." in result
# Should not contain the full prompt
assert long_prompt not in result
@pytest.mark.asyncio
async def test_task_id_is_unique(self):
"""Each background task gets a unique task ID."""
runner = _make_runner()
task_ids = set()
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
for i in range(5):
event = _make_event(text=f"/background task {i}")
result = await runner._handle_background_command(event)
# Extract task ID from result (format: "Task ID: bg_HHMMSS_hex")
for line in result.split("\n"):
if "Task ID:" in line:
tid = line.split("Task ID:")[1].strip()
task_ids.add(tid)
assert len(task_ids) == 5 # all unique
@pytest.mark.asyncio
async def test_works_across_platforms(self):
"""The /background command works for all platforms."""
for platform in [Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK]:
runner = _make_runner()
with patch("gateway.run.asyncio.create_task", side_effect=lambda c, **kw: (c.close(), MagicMock())[1]):
event = _make_event(
text="/background test task",
platform=platform,
)
result = await runner._handle_background_command(event)
assert "Background task started" in result
# ---------------------------------------------------------------------------
# _run_background_task
# ---------------------------------------------------------------------------
class TestRunBackgroundTask:
"""Tests for GatewayRunner._run_background_task (the actual execution)."""
@pytest.mark.asyncio
async def test_no_adapter_returns_silently(self):
"""When no adapter is available, the task returns without error."""
runner = _make_runner()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
# No adapters set — should not raise
await runner._run_background_task("test prompt", source, "bg_test")
@pytest.mark.asyncio
async def test_no_credentials_sends_error(self):
"""When provider credentials are missing, an error is sent."""
runner = _make_runner()
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": None}):
await runner._run_background_task("test prompt", source, "bg_test")
# Should have sent an error message
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
assert "failed" in call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "").lower()
@pytest.mark.asyncio
async def test_successful_task_sends_result(self):
"""When the agent completes successfully, the result is sent."""
runner = _make_runner()
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
mock_adapter.extract_media = MagicMock(return_value=([], "Hello from background!"))
mock_adapter.extract_images = MagicMock(return_value=([], "Hello from background!"))
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
mock_result = {"final_response": "Hello from background!", "messages": []}
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
patch("run_agent.AIAgent") as MockAgent:
mock_agent_instance = MagicMock()
mock_agent_instance.run_conversation.return_value = mock_result
MockAgent.return_value = mock_agent_instance
await runner._run_background_task("say hello", source, "bg_test")
# Should have sent the result
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
assert "Background task complete" in content
assert "Hello from background!" in content
@pytest.mark.asyncio
async def test_exception_sends_error_message(self):
"""When the agent raises an exception, an error message is sent."""
runner = _make_runner()
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
with patch("gateway.run._resolve_runtime_agent_kwargs", side_effect=RuntimeError("boom")):
await runner._run_background_task("test prompt", source, "bg_test")
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
assert "failed" in content.lower()
# ---------------------------------------------------------------------------
# /background in help and known_commands
# ---------------------------------------------------------------------------
class TestBackgroundInHelp:
"""Verify /background appears in help text and known commands."""
@pytest.mark.asyncio
async def test_background_in_help_output(self):
"""The /help output includes /background."""
runner = _make_runner()
event = _make_event(text="/help")
result = await runner._handle_help_command(event)
assert "/background" in result
def test_background_is_known_command(self):
"""The /background command is in the _known_commands set."""
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"background"' in source
# ---------------------------------------------------------------------------
# CLI /background command definition
# ---------------------------------------------------------------------------
class TestBackgroundInCLICommands:
"""Verify /background is registered in the CLI command system."""
def test_background_in_commands_dict(self):
"""The /background command is in the COMMANDS dict."""
from hermes_cli.commands import COMMANDS
assert "/background" in COMMANDS
def test_background_in_session_category(self):
"""The /background command is in the Session category."""
from hermes_cli.commands import COMMANDS_BY_CATEGORY
assert "/background" in COMMANDS_BY_CATEGORY["Session"]
def test_background_autocompletes(self):
"""The /background command appears in autocomplete results."""
from hermes_cli.commands import SlashCommandCompleter
from prompt_toolkit.document import Document
completer = SlashCommandCompleter()
doc = Document("backgro") # Partial match
completions = list(completer.get_completions(doc, None))
# Text doesn't start with / so no completions
assert len(completions) == 0
doc = Document("/backgro") # With slash prefix
completions = list(completer.get_completions(doc, None))
cmd_displays = [str(c.display) for c in completions]
assert any("/background" in d for d in cmd_displays)

View File

@@ -12,7 +12,7 @@ EXPECTED_COMMANDS = {
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
"/verbose", "/compress", "/title", "/usage", "/insights", "/paste",
"/reload-mcp", "/rollback", "/skin", "/quit",
"/reload-mcp", "/rollback", "/background", "/skin", "/quit",
}