mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
feat(skills): stacked slash-skill invocations — /skill-a /skill-b do XYZ (#57987)
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.
This commit is contained in:
parent
30479961b8
commit
9767e19b60
5 changed files with 363 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
34
cli.py
34
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue