From 9767e19b6071c5ec3d489dfd00cab605ccb4b1bb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:20:01 -0700 Subject: [PATCH] =?UTF-8?q?feat(skills):=20stacked=20slash-skill=20invocat?= =?UTF-8?q?ions=20=E2=80=94=20/skill-a=20/skill-b=20do=20XYZ=20(#57987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by Claude Code v2.1.199 (July 2, 2026): stacked slash-skill invocations load all leading skills (up to 5), not just the first. - agent/skill_commands.py: split_stacked_skill_commands() consumes leading /skill tokens (stops at the first non-skill token so slash-path arguments are never swallowed); build_stacked_skill_invocation_message() composes the multi-skill turn reusing the existing bundle scaffolding markers so extract_user_instruction_from_skill_message() keeps memory providers storing the user's instruction, not N skill bodies. - cli.py + gateway/run.py: dispatch the stacked path on both surfaces. - 11 new tests + docs section in skills.md. --- agent/skill_commands.py | 131 ++++++++++++++++++ cli.py | 34 ++++- gateway/run.py | 39 +++++- tests/agent/test_skill_commands.py | 146 +++++++++++++++++++++ website/docs/user-guide/features/skills.md | 20 +++ 5 files changed, 363 insertions(+), 7 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 18264c44bd3..37b70a5b42c 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -561,6 +561,137 @@ def build_skill_invocation_message( ) +# --------------------------------------------------------------------------- +# Stacked slash-skill invocations — `/skill-a /skill-b do XYZ` loads every +# leading skill (up to _MAX_STACKED_SKILLS), not just the first. +# +# Inspired by Claude Code v2.1.199 (July 2, 2026): "Stacked slash-skill +# invocations like /skill-a /skill-b do XYZ now load all leading skills +# (up to 5), not just the first." +# +# The generated message deliberately reuses the BUNDLE scaffolding markers +# ("skill bundle," header + "[Loaded as part of the " block prefix) so +# extract_user_instruction_from_skill_message() recovers the user's +# instruction without any new marker plumbing — memory providers keep +# storing what the user actually asked, not N skill bodies. +# --------------------------------------------------------------------------- +_MAX_STACKED_SKILLS = 5 + + +def split_stacked_skill_commands(rest: str) -> tuple[list[str], str]: + """Consume additional leading ``/skill`` tokens from *rest*. + + *rest* is the text that follows the FIRST matched skill command (the + caller has already resolved that one). Leading whitespace-delimited + tokens that start with ``/`` and resolve to installed skill commands are + consumed, up to ``_MAX_STACKED_SKILLS`` total leading skills (i.e. at + most ``_MAX_STACKED_SKILLS - 1`` extra keys here). Parsing stops at the + first token that is not a resolvable skill command — that token and + everything after it become the user instruction. + + Returns: + ``(extra_cmd_keys, remaining_instruction)`` where ``extra_cmd_keys`` + are canonical ``/slug`` keys from :func:`get_skill_commands`. + """ + keys: list[str] = [] + remaining = rest or "" + while len(keys) < _MAX_STACKED_SKILLS - 1: + stripped = remaining.lstrip() + if not stripped.startswith("/"): + break + parts = stripped.split(None, 1) + token = parts[0] + tail = parts[1] if len(parts) > 1 else "" + cmd_key = resolve_skill_command_key(token.lstrip("/")) + if cmd_key is None or cmd_key in keys: + break + keys.append(cmd_key) + remaining = tail + return keys, remaining.strip() + + +def build_stacked_skill_invocation_message( + cmd_keys: list[str], + user_instruction: str = "", + task_id: str | None = None, +) -> Optional[tuple[str, list[str], list[str]]]: + """Build the user message for a stacked multi-skill slash invocation. + + Args: + cmd_keys: Canonical ``/slug`` keys, in the order the user typed them. + user_instruction: Text remaining after the leading skill commands. + + Returns: + ``(message, loaded_skill_names, missing_skill_names)`` or ``None`` + when no skill could be loaded at all. + """ + commands = get_skill_commands() + + loaded_names: list[str] = [] + missing: list[str] = [] + skill_blocks: list[str] = [] + seen: set[str] = set() + + for cmd_key in cmd_keys: + if not cmd_key or cmd_key in seen: + continue + seen.add(cmd_key) + + skill_info = commands.get(cmd_key) + if not skill_info: + missing.append(cmd_key.lstrip("/")) + continue + + loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id) + if not loaded: + missing.append(cmd_key.lstrip("/")) + continue + loaded_skill, skill_dir, skill_name = loaded + + # Track active usage for Curator lifecycle management (#17782) + try: + from tools.skill_usage import bump_use + bump_use(skill_name) + except Exception: + pass # Non-critical + + # NOTE: must start with "[Loaded as part of the " — that prefix is + # the bundle block marker the memory-scaffolding extractor cuts on. + activation_note = ( + f'[Loaded as part of the stacked skill invocation "{skill_name}".]' + ) + skill_blocks.append( + _build_skill_message( + loaded_skill, + skill_dir, + activation_note, + session_id=task_id, + ) + ) + loaded_names.append(skill_name) + + if not skill_blocks: + return None + + # Header — must contain " skill bundle," so the bundle-format extractor + # in extract_user_instruction_from_skill_message() applies unchanged. + typed = " ".join(k for k in cmd_keys if k) + header_lines = [ + f'[IMPORTANT: The user has invoked the "{typed}" stacked skill bundle, ' + f"loading {len(loaded_names)} skills together. Treat every skill below " + "as active guidance for this turn.]", + "", + f"Skills loaded: {', '.join(loaded_names)}", + ] + if missing: + header_lines.append(f"Skills missing (skipped): {', '.join(missing)}") + if user_instruction: + header_lines.extend(["", f"User instruction: {user_instruction}"]) + + header = "\n".join(header_lines) + return ("\n\n".join([header, *skill_blocks]), loaded_names, missing) + + def build_preloaded_skills_prompt( skill_identifiers: list[str], task_id: str | None = None, diff --git a/cli.py b/cli.py index d2dbbbb0195..6091e3907bd 100644 --- a/cli.py +++ b/cli.py @@ -8890,7 +8890,39 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): ) # Check for skill slash commands (/gif-search, /axolotl, etc.) elif base_cmd in skill_commands: - user_instruction = cmd_original[len(base_cmd):].strip() + rest = cmd_original[len(base_cmd):].strip() + # Stacked slash-skill invocations: `/skill-a /skill-b do XYZ` + # loads every leading skill (up to 5), not just the first. + # Inspired by Claude Code v2.1.199. + from agent.skill_commands import ( + build_stacked_skill_invocation_message, + split_stacked_skill_commands, + ) + extra_keys, user_instruction = split_stacked_skill_commands(rest) + if extra_keys: + stacked_result = build_stacked_skill_invocation_message( + [base_cmd, *extra_keys], + user_instruction, + task_id=self.session_id, + ) + if stacked_result: + msg, loaded_names, missing = stacked_result + print( + f"\n⚡ Loading {len(loaded_names)} stacked skills: " + f"{', '.join(loaded_names)}" + ) + if missing: + ChatConsole().print( + f"[yellow]Skipped missing skills: {', '.join(missing)}[/]" + ) + if hasattr(self, '_pending_input'): + self._pending_input.put(msg) + else: + ChatConsole().print( + f"[bold red]Failed to load stacked skills for {base_cmd}[/]" + ) + return True + user_instruction = rest msg = build_skill_invocation_message( base_cmd, user_instruction, task_id=self.session_id ) diff --git a/gateway/run.py b/gateway/run.py index 6fc7ded69db..7e50f73638c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9846,12 +9846,39 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew f"Enable it with: `hermes skills config`" ) user_instruction = event.get_command_args().strip() - msg = build_skill_invocation_message( - cmd_key, user_instruction, task_id=_quick_key - ) - if msg: - event.text = msg - # Fall through to normal message processing with skill content + # Stacked slash-skill invocations: `/skill-a /skill-b do + # XYZ` loads every leading skill (up to 5), not just the + # first. Inspired by Claude Code v2.1.199. Mirrors CLI. + try: + from agent.skill_commands import ( + build_stacked_skill_invocation_message as _build_stacked, + split_stacked_skill_commands, + ) + extra_keys, stacked_instruction = ( + split_stacked_skill_commands(user_instruction) + ) + except Exception: + _build_stacked = None + extra_keys, stacked_instruction = [], user_instruction + if extra_keys and _build_stacked is not None: + stacked_result = _build_stacked( + [cmd_key, *extra_keys], + stacked_instruction, + task_id=_quick_key, + ) + if stacked_result: + msg, _loaded, _missing = stacked_result + event.text = msg + # Fall through to normal message processing + else: + return f"Failed to load stacked skills for /{command}." + else: + msg = build_skill_invocation_message( + cmd_key, user_instruction, task_id=_quick_key + ) + if msg: + event.text = msg + # Fall through to normal message processing with skill content else: # Not an active skill — check if it's a known-but-disabled or # uninstalled skill and give actionable guidance. diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 192ad0d0b35..974fad9c45c 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -803,3 +803,149 @@ class TestInlineShellExpansion: # The command's intended stdout never made it through — only the # timeout marker (which echoes the command text) survives. assert "DYN_MARKER" not in msg.replace("sleep 5 && printf DYN_MARKER", "") + + +class TestStackedSkillCommands: + """Stacked slash-skill invocations — inspired by Claude Code v2.1.199.""" + + def _setup_three_skills(self, tmp_path): + _make_skill(tmp_path, "skill-a", body="Body A.") + _make_skill(tmp_path, "skill-b", body="Body B.") + _make_skill(tmp_path, "skill-c", body="Body C.") + + def test_split_consumes_leading_skill_tokens(self, tmp_path): + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + keys, instruction = split_stacked_skill_commands( + "/skill-b /skill-c do the thing" + ) + assert keys == ["/skill-b", "/skill-c"] + assert instruction == "do the thing" + + def test_split_stops_at_non_skill_token(self, tmp_path): + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + keys, instruction = split_stacked_skill_commands( + "/skill-b /not-a-skill /skill-c hello" + ) + assert keys == ["/skill-b"] + # Parsing stops at the first unresolvable token; everything from + # there on is the user instruction (slash included). + assert instruction == "/not-a-skill /skill-c hello" + + def test_split_plain_instruction_passthrough(self, tmp_path): + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + keys, instruction = split_stacked_skill_commands("just do the thing") + assert keys == [] + assert instruction == "just do the thing" + + def test_split_underscore_form_resolves(self, tmp_path): + """Telegram autocomplete sends /skill_b — must resolve like /skill-b.""" + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + keys, instruction = split_stacked_skill_commands("/skill_b go") + assert keys == ["/skill-b"] + assert instruction == "go" + + def test_split_caps_at_five_total(self, tmp_path): + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + for i in range(7): + _make_skill(tmp_path, f"stk-{i}") + scan_skill_commands() + rest = " ".join(f"/stk-{i}" for i in range(1, 7)) + " run" + keys, instruction = split_stacked_skill_commands(rest) + # First skill was already consumed by the caller — split returns at + # most 4 extras so the total stays at 5. + assert len(keys) == 4 + assert instruction.startswith("/stk-5") + + def test_split_dedupes_repeated_skill(self, tmp_path): + from agent.skill_commands import split_stacked_skill_commands + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + keys, instruction = split_stacked_skill_commands( + "/skill-b /skill-b go" + ) + # The duplicate stops parsing (treated as instruction text). + assert keys == ["/skill-b"] + assert instruction == "/skill-b go" + + def test_stacked_message_contains_all_bodies_and_instruction(self, tmp_path): + from agent.skill_commands import build_stacked_skill_invocation_message + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + result = build_stacked_skill_invocation_message( + ["/skill-a", "/skill-b"], "do the thing" + ) + assert result is not None + msg, loaded, missing = result + assert loaded == ["skill-a", "skill-b"] + assert missing == [] + assert "Body A." in msg + assert "Body B." in msg + assert "User instruction: do the thing" in msg + + def test_stacked_message_skips_missing_skills(self, tmp_path): + from agent.skill_commands import build_stacked_skill_invocation_message + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + result = build_stacked_skill_invocation_message( + ["/skill-a", "/gone"], "go" + ) + assert result is not None + msg, loaded, missing = result + assert loaded == ["skill-a"] + assert missing == ["gone"] + assert "Skills missing (skipped): gone" in msg + + def test_stacked_message_none_when_nothing_loads(self, tmp_path): + from agent.skill_commands import build_stacked_skill_invocation_message + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + scan_skill_commands() + result = build_stacked_skill_invocation_message(["/gone"], "go") + assert result is None + + def test_memory_extractor_recovers_instruction_from_stacked_turn(self, tmp_path): + """The stacked scaffolding reuses bundle markers so memory providers + recover the user's instruction, not N skill bodies.""" + from agent.skill_commands import ( + build_stacked_skill_invocation_message, + extract_user_instruction_from_skill_message, + ) + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + result = build_stacked_skill_invocation_message( + ["/skill-a", "/skill-b"], "summarize the repo" + ) + assert result is not None + msg, _, _ = result + assert extract_user_instruction_from_skill_message(msg) == "summarize the repo" + + def test_memory_extractor_returns_none_for_bare_stacked_turn(self, tmp_path): + from agent.skill_commands import ( + build_stacked_skill_invocation_message, + extract_user_instruction_from_skill_message, + ) + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + self._setup_three_skills(tmp_path) + scan_skill_commands() + result = build_stacked_skill_invocation_message( + ["/skill-a", "/skill-b"], "" + ) + assert result is not None + msg, _, _ = result + assert extract_user_instruction_from_skill_message(msg) is None diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 18dd93c1262..19fffb1f1b2 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -62,6 +62,26 @@ Every installed skill is automatically available as a slash command: /excalidraw ``` +### Stacking multiple skills in one command + +You can invoke several skills in a single message by chaining slash commands +at the start — every leading `/skill` token (up to 5) is loaded, and the rest +becomes your instruction: + +```bash +/github-pr-workflow /test-driven-development fix issue #123 and open a PR +``` + +Parsing stops at the first token that isn't an installed skill, so arguments +that happen to start with `/` (like file paths) are never swallowed: + +```bash +/ocr-and-documents /tmp/scan.pdf extract the tables # loads one skill; /tmp/scan.pdf is the argument +``` + +For combinations you use repeatedly, prefer a [skill bundle](#skill-bundles) — +same effect under one short command. + The bundled `plan` skill is a good example. Running `/plan [request]` loads the skill's instructions, telling Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `.hermes/plans/` relative to the active workspace/backend working directory. You can also interact with skills through natural conversation: