diff --git a/gateway/run.py b/gateway/run.py index be89833acd..f7703f9be7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 ` — 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 — 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 \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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 0d9a796ba6..22e56b3fc6 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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 )", }, "Configuration": { "/config": "Show current configuration", diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py new file mode 100644 index 0000000000..6a780fb13f --- /dev/null +++ b/tests/gateway/test_background_command.py @@ -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) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index ec81fbeed3..0aead5c33e 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -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", }