From 11f8744ddc261d115639f3c54be45fc4cac0cb06 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 20:54:23 -0700 Subject: [PATCH] feat: add /plan command --- agent/skill_commands.py | 66 +++++++++++++++++- cli.py | 25 ++++++- gateway/run.py | 19 +++++- hermes_cli/commands.py | 1 + tests/agent/test_skill_commands.py | 40 ++++++++++- tests/gateway/test_plan_command.py | 103 +++++++++++++++++++++++++++++ tests/hermes_cli/test_commands.py | 2 +- tests/test_cli_plan_command.py | 41 ++++++++++++ 8 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_plan_command.py create mode 100644 tests/test_cli_plan_command.py diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 76bd204d59..e92ebdcac3 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -1,17 +1,79 @@ -"""Skill slash commands — scan installed skills and build invocation messages. +"""Shared slash command helpers for skills and built-in prompt-style modes. Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces -can invoke skills via /skill-name commands. +can invoke skills via /skill-name commands and prompt-only built-ins like +/plan. """ import json import logging +import os +import re +from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional logger = logging.getLogger(__name__) _skill_commands: Dict[str, Dict[str, Any]] = {} +_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def build_plan_path( + user_instruction: str = "", + *, + now: datetime | None = None, +) -> Path: + """Return the default markdown path for a /plan invocation.""" + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else "" + slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-") + if slug: + slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-") + slug = slug or "conversation-plan" + timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S") + return hermes_home / "plans" / f"{timestamp}-{slug}.md" + + +def build_plan_invocation_message( + user_instruction: str = "", + *, + plan_path: str | Path | None = None, +) -> str: + """Build the injected user message for the built-in /plan command.""" + resolved_path = Path(plan_path) if plan_path is not None else build_plan_path(user_instruction) + + parts = [ + '[SYSTEM: The user has invoked the "/plan" command. This means they want a markdown plan, not execution, for this turn.]', + "", + "You are in plan mode for this turn.", + "", + "Plan mode rules:", + "- Do not implement code, edit project files other than the plan document, run mutating terminal commands, commit, push, or take external actions.", + "- You may inspect the repo/context and use read-only tools or commands if needed.", + f"- Write the finished plan as markdown and save it with write_file to: {resolved_path}", + "- Make the plan concrete and actionable.", + "- Include: goal, context/assumptions, proposed approach, step-by-step plan, validation, and risks/open questions.", + "- If the task is code-related, include exact file paths, tests, and rollout or verification notes when possible.", + "- After saving the plan, reply with a short summary and the saved path.", + ] + + if user_instruction: + parts.extend( + [ + "", + f"The user wants a plan for: {user_instruction}", + ] + ) + else: + parts.extend( + [ + "", + "The user wants a plan based on the current conversation context. Infer the active task from the latest discussion, and only ask clarifying questions if the request is genuinely underspecified.", + ] + ) + + return "\n".join(parts) def scan_skill_commands() -> Dict[str, Dict[str, Any]]: diff --git a/cli.py b/cli.py index 13bf4736b4..cd058ea78d 100755 --- a/cli.py +++ b/cli.py @@ -1043,7 +1043,13 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic # Skill Slash Commands — dynamic commands generated from installed skills # ============================================================================ -from agent.skill_commands import scan_skill_commands, get_skill_commands, build_skill_invocation_message +from agent.skill_commands import ( + scan_skill_commands, + get_skill_commands, + build_skill_invocation_message, + build_plan_invocation_message, + build_plan_path, +) _skill_commands = scan_skill_commands() @@ -3013,6 +3019,8 @@ class HermesCLI: elif cmd_lower.startswith("/personality"): # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif cmd_lower == "/plan" or cmd_lower.startswith("/plan "): + self._handle_plan_command(cmd_original) elif cmd_lower == "/retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): @@ -3124,6 +3132,21 @@ class HermesCLI: return True + def _handle_plan_command(self, cmd: str): + """Handle /plan [request] — queue a markdown planning request.""" + parts = cmd.strip().split(maxsplit=1) + user_instruction = parts[1].strip() if len(parts) > 1 else "" + + plan_path = build_plan_path(user_instruction) + plan_path.parent.mkdir(parents=True, exist_ok=True) + msg = build_plan_invocation_message(user_instruction, plan_path=plan_path) + + _cprint(f" 📝 Plan mode queued. Markdown plan target: {plan_path}") + if hasattr(self, '_pending_input'): + self._pending_input.put(msg) + else: + self.console.print("[bold red]Plan mode unavailable: input queue not initialized[/]") + def _handle_background_command(self, cmd: str): """Handle /background — run a prompt in a separate background session. diff --git a/gateway/run.py b/gateway/run.py index e97db02556..6b3bffdcce 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1098,7 +1098,7 @@ class GatewayRunner: # Emit command:* hook for any recognized slash command _known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning", - "personality", "retry", "undo", "sethome", "set-home", + "personality", "plan", "retry", "undo", "sethome", "set-home", "compress", "usage", "insights", "reload-mcp", "reload_mcp", "update", "title", "resume", "provider", "rollback", "background", "reasoning", "voice"} @@ -1133,6 +1133,22 @@ class GatewayRunner: if command == "personality": return await self._handle_personality_command(event) + + if command == "plan": + try: + from agent.skill_commands import build_plan_invocation_message, build_plan_path + + user_instruction = event.get_command_args().strip() + plan_path = build_plan_path(user_instruction) + plan_path.parent.mkdir(parents=True, exist_ok=True) + event.text = build_plan_invocation_message( + user_instruction, + plan_path=plan_path, + ) + command = None + except Exception as e: + logger.exception("Failed to prepare /plan command") + return f"Failed to enter plan mode: {e}" if command == "retry": return await self._handle_retry_command(event) @@ -1854,6 +1870,7 @@ class GatewayRunner: "`/model [provider:model]` — Show/change model (or switch provider)", "`/provider` — Show available providers and auth status", "`/personality [name]` — Set a personality", + "`/plan [request]` — Write/save a markdown plan instead of taking action", "`/retry` — Retry your last message", "`/undo` — Remove the last exchange", "`/sethome` — Set this chat as the home channel", diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a9a1a67ba7..6c97454b2e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -21,6 +21,7 @@ COMMANDS_BY_CATEGORY = { "/clear": "Clear screen and start a new session", "/history": "Show conversation history", "/save": "Save the current conversation", + "/plan": "Write/save a markdown plan instead of taking action (usage: /plan [request])", "/retry": "Retry the last message (resend to agent)", "/undo": "Remove the last user/assistant exchange", "/title": "Set a title for the current session (usage: /title My Session Name)", diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 2e2ac64ccd..7d7452b3e7 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -1,10 +1,16 @@ """Tests for agent/skill_commands.py — skill slash command scanning and platform filtering.""" import os +from datetime import datetime from unittest.mock import patch import tools.skills_tool as skills_tool_module -from agent.skill_commands import scan_skill_commands, build_skill_invocation_message +from agent.skill_commands import ( + build_plan_invocation_message, + build_plan_path, + build_skill_invocation_message, + scan_skill_commands, +) def _make_skill( @@ -241,3 +247,35 @@ Generate some audio. assert msg is not None assert 'file_path=""' in msg + + +class TestBuildPlanInvocationMessage: + def test_build_plan_path_uses_hermes_home_and_slugifies_request(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + path = build_plan_path( + "Implement OAuth login + refresh tokens!", + now=datetime(2026, 3, 15, 9, 30, 45), + ) + + assert path == tmp_path / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md" + + def test_build_plan_invocation_message_includes_rules_and_target_path(self, tmp_path): + plan_path = tmp_path / "plans" / "my-plan.md" + + msg = build_plan_invocation_message( + "Add a /plan command", + plan_path=plan_path, + ) + + assert '"/plan" command' in msg + assert str(plan_path) in msg + assert "Do not implement code" in msg + assert "Add a /plan command" in msg + assert "write_file" in msg + + def test_build_plan_invocation_message_without_instruction_uses_conversation_context(self, tmp_path): + msg = build_plan_invocation_message(plan_path=tmp_path / "plans" / "conversation.md") + + assert "current conversation context" in msg + assert "genuinely underspecified" in msg diff --git a/tests/gateway/test_plan_command.py b/tests/gateway/test_plan_command.py new file mode 100644 index 0000000000..9106d6fb81 --- /dev/null +++ b/tests/gateway/test_plan_command.py @@ -0,0 +1,103 @@ +"""Tests for the /plan gateway slash command.""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = {} + runner._voice_mode = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = SessionEntry( + session_key="agent:main:telegram:dm:c1:u1", + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._run_agent = AsyncMock( + return_value={ + "final_response": "planned", + "messages": [], + "tools": [], + "history_offset": 0, + "last_prompt_tokens": 0, + } + ) + return runner + + +def _make_event(text="/plan"): + return MessageEvent( + text=text, + source=SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ), + message_id="m1", + ) + + +class TestGatewayPlanCommand: + @pytest.mark.asyncio + async def test_plan_command_rewrites_message_and_runs_agent(self, monkeypatch, tmp_path): + import gateway.run as gateway_run + + runner = _make_runner() + event = _make_event("/plan Add OAuth login") + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}) + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 100_000, + ) + + result = await runner._handle_message(event) + + assert result == "planned" + forwarded = runner._run_agent.call_args.kwargs["message"] + assert '"/plan" command' in forwarded + assert "Add OAuth login" in forwarded + assert str(tmp_path / "plans") in forwarded + + @pytest.mark.asyncio + async def test_plan_command_appears_in_help_output(self): + runner = _make_runner() + event = _make_event("/help") + + result = await runner._handle_help_command(event) + + assert "/plan" in result diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 218059434a..1e24c22eb5 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -9,7 +9,7 @@ from hermes_cli.commands import COMMANDS, SlashCommandCompleter # All commands that must be present in the shared COMMANDS dict. EXPECTED_COMMANDS = { "/help", "/tools", "/toolsets", "/model", "/provider", "/prompt", - "/personality", "/clear", "/history", "/new", "/reset", "/retry", + "/personality", "/clear", "/history", "/new", "/reset", "/plan", "/retry", "/undo", "/save", "/config", "/cron", "/skills", "/platforms", "/verbose", "/reasoning", "/compress", "/title", "/usage", "/insights", "/paste", "/reload-mcp", "/rollback", "/background", "/skin", "/voice", "/quit", diff --git a/tests/test_cli_plan_command.py b/tests/test_cli_plan_command.py new file mode 100644 index 0000000000..80e64a0ce4 --- /dev/null +++ b/tests/test_cli_plan_command.py @@ -0,0 +1,41 @@ +"""Tests for the /plan CLI slash command.""" + +from unittest.mock import MagicMock + +from cli import HermesCLI + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = "sess-123" + cli_obj._pending_input = MagicMock() + return cli_obj + + +class TestCLIPlanCommand: + def test_plan_command_queues_plan_mode_message(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cli_obj = _make_cli() + + result = cli_obj.process_command("/plan Add OAuth login") + + assert result is True + cli_obj._pending_input.put.assert_called_once() + queued = cli_obj._pending_input.put.call_args[0][0] + assert '"/plan" command' in queued + assert "Add OAuth login" in queued + assert str(tmp_path / "plans") in queued + + def test_plan_without_args_uses_conversation_context(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + cli_obj = _make_cli() + + cli_obj.process_command("/plan") + + queued = cli_obj._pending_input.put.call_args[0][0] + assert "current conversation context" in queued + assert "conversation-plan" in queued