Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 43 additions & 11 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, Literal, Optional

from hermes_constants import display_hermes_home

Expand All @@ -23,6 +24,25 @@
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")


@dataclass(frozen=True)
class SkillInvocationResult:
"""Explicit outcome for slash-command skill loading."""

status: Literal["ok", "unknown_command", "load_failed"]
command: str
message: str | None = None
skill_name: str | None = None

@property
def ok(self) -> bool:
return self.status == "ok"

def __bool__(self) -> bool:
raise TypeError(
"SkillInvocationResult does not support truthiness; use .ok or .status."
)


def build_plan_path(
user_instruction: str = "",
*,
Expand Down Expand Up @@ -302,36 +322,48 @@ def build_skill_invocation_message(
user_instruction: str = "",
task_id: str | None = None,
runtime_note: str = "",
) -> Optional[str]:
) -> SkillInvocationResult:
"""Build the user message content for a skill slash command invocation.

Args:
cmd_key: The command key including leading slash (e.g., "/gif-search").
user_instruction: Optional text the user typed after the command.

Returns:
The formatted message string, or None if the skill wasn't found.
A structured result describing whether the command was found and loaded.
"""
commands = get_skill_commands()
skill_info = commands.get(cmd_key)
if not skill_info:
return None
return SkillInvocationResult(
status="unknown_command",
command=cmd_key,
)

loaded = _load_skill_payload(skill_info["skill_dir"], task_id=task_id)
if not loaded:
return f"[Failed to load skill: {skill_info['name']}]"
return SkillInvocationResult(
status="load_failed",
command=cmd_key,
skill_name=str(skill_info.get("name") or cmd_key.lstrip("/")),
)

loaded_skill, skill_dir, skill_name = loaded
activation_note = (
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want '
"you to follow its instructions. The full skill content is loaded below.]"
)
return _build_skill_message(
loaded_skill,
skill_dir,
activation_note,
user_instruction=user_instruction,
runtime_note=runtime_note,
return SkillInvocationResult(
status="ok",
command=cmd_key,
message=_build_skill_message(
loaded_skill,
skill_dir,
activation_note,
user_instruction=user_instruction,
runtime_note=runtime_note,
),
skill_name=skill_name,
)


Expand Down
14 changes: 7 additions & 7 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5640,14 +5640,14 @@ def process_command(self, command: str) -> bool:
# Check for skill slash commands (/gif-search, /axolotl, etc.)
elif base_cmd in _skill_commands:
user_instruction = cmd_original[len(base_cmd):].strip()
msg = build_skill_invocation_message(
invocation = build_skill_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if msg:
skill_name = _skill_commands[base_cmd]["name"]
if invocation.ok:
skill_name = invocation.skill_name or _skill_commands[base_cmd]["name"]
print(f"\n⚡ Loading skill: {skill_name}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
self._pending_input.put(invocation.message)
else:
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
else:
Expand Down Expand Up @@ -5699,7 +5699,7 @@ def _handle_plan_command(self, cmd: str):
user_instruction = parts[1].strip() if len(parts) > 1 else ""

plan_path = build_plan_path(user_instruction)
msg = build_skill_invocation_message(
invocation = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=self.session_id,
Expand All @@ -5709,13 +5709,13 @@ def _handle_plan_command(self, cmd: str):
),
)

if not msg:
if not invocation.ok:
ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]")
return

_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
self._pending_input.put(invocation.message)
else:
ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]")

Expand Down
11 changes: 8 additions & 3 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,17 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
for skill_name in skills:
cmd_key = f"/{skill_name}"
if cmd_key in skill_cmds:
skill_content = build_skill_invocation_message(
invocation = build_skill_invocation_message(
cmd_key, user_instruction=prompt
)
if skill_content:
prompt = skill_content
if invocation.ok:
prompt = invocation.message
break # Load the first matching skill
if invocation.status == "load_failed":
logger.warning(
"[webhook] Failed to load skill '%s' after discovery",
invocation.skill_name or skill_name,
)
else:
logger.warning(
"[webhook] Skill '%s' not found", skill_name
Expand Down
23 changes: 18 additions & 5 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3066,7 +3066,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:

user_instruction = event.get_command_args().strip()
plan_path = build_plan_path(user_instruction)
event.text = build_skill_invocation_message(
invocation = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=_quick_key,
Expand All @@ -3075,8 +3075,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
f"inside the active workspace/backend cwd: {plan_path}"
),
)
if not event.text:
if not invocation.ok:
return "Failed to load the bundled /plan skill."
event.text = invocation.message
canonical = None
except Exception as e:
logger.exception("Failed to prepare /plan command")
Expand Down Expand Up @@ -3227,12 +3228,24 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
f"Enable it with: `hermes skills config`"
)
user_instruction = event.get_command_args().strip()
msg = build_skill_invocation_message(
invocation = build_skill_invocation_message(
cmd_key, user_instruction, task_id=_quick_key
)
if msg:
event.text = msg
if invocation.ok:
event.text = invocation.message
# Fall through to normal message processing with skill content
elif invocation.status == "load_failed":
return (
f"Failed to load the **{invocation.skill_name or cmd_key.lstrip('/')}** "
"skill."
)
else:
return (
f"Unknown command `/{command}`. "
f"Type /commands to see what's available, "
f"or resend without the leading slash to send "
f"as a regular message."
)
else:
# Not an active skill — check if it's a known-but-disabled or
# uninstalled skill and give actionable guidance.
Expand Down
85 changes: 56 additions & 29 deletions tests/agent/test_skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
from pathlib import Path
from unittest.mock import patch

import pytest
import tools.skills_tool as skills_tool_module
from agent.skill_commands import (
SkillInvocationResult,
build_plan_path,
build_preloaded_skills_prompt,
build_skill_invocation_message,
Expand Down Expand Up @@ -238,26 +240,46 @@ def test_loads_skill_by_stored_path_when_frontmatter_name_differs(self, tmp_path

with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
scan_skill_commands()
msg = build_skill_invocation_message("/audiocraft-audio-generation", "compose")
result = build_skill_invocation_message("/audiocraft-audio-generation", "compose")

assert msg is not None
assert "AudioCraft" in msg
assert "compose" in msg
assert result.ok
assert result.message is not None
assert "AudioCraft" in result.message
assert "compose" in result.message

def test_builds_message(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(tmp_path, "test-skill")
scan_skill_commands()
msg = build_skill_invocation_message("/test-skill", "do stuff")
assert msg is not None
assert "test-skill" in msg
assert "do stuff" in msg
result = build_skill_invocation_message("/test-skill", "do stuff")
assert result.ok
assert result.message is not None
assert "test-skill" in result.message
assert "do stuff" in result.message

def test_returns_none_for_unknown(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
scan_skill_commands()
msg = build_skill_invocation_message("/nonexistent")
assert msg is None
result = build_skill_invocation_message("/nonexistent")
assert result.status == "unknown_command"
assert result.message is None

def test_bool_is_not_allowed_for_explicit_result_contract(self):
result = SkillInvocationResult(status="ok", command="/test-skill", message="prompt")

with pytest.raises(TypeError, match="use \\.ok or \\.status"):
bool(result)

def test_returns_load_failed_for_post_scan_failure(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
skill_dir = _make_skill(tmp_path, "test-skill")
scan_skill_commands()
(skill_dir / "SKILL.md").unlink()
result = build_skill_invocation_message("/test-skill", "do stuff")

assert result.status == "load_failed"
assert result.skill_name == "test-skill"
assert result.message is None

def test_uses_shared_skill_loader_for_secure_setup(self, tmp_path, monkeypatch):
monkeypatch.delenv("TENOR_API_KEY", raising=False)
Expand Down Expand Up @@ -291,10 +313,11 @@ def fake_secret_callback(var_name, prompt, metadata=None):
),
)
scan_skill_commands()
msg = build_skill_invocation_message("/test-skill", "do stuff")
result = build_skill_invocation_message("/test-skill", "do stuff")

assert msg is not None
assert "test-skill" in msg
assert result.ok
assert result.message is not None
assert "test-skill" in result.message
assert len(calls) == 1
assert calls[0][0] == "TENOR_API_KEY"

Expand Down Expand Up @@ -329,10 +352,11 @@ def fail_if_called(var_name, prompt, metadata=None):
),
)
scan_skill_commands()
msg = build_skill_invocation_message("/test-skill", "do stuff")
result = build_skill_invocation_message("/test-skill", "do stuff")

assert msg is not None
assert "local cli" in msg.lower()
assert result.ok
assert result.message is not None
assert "local cli" in result.message.lower()

def test_preserves_remaining_remote_setup_warning(self, tmp_path, monkeypatch):
monkeypatch.setenv("TERMINAL_ENV", "ssh")
Expand All @@ -355,10 +379,11 @@ def test_preserves_remaining_remote_setup_warning(self, tmp_path, monkeypatch):
),
)
scan_skill_commands()
msg = build_skill_invocation_message("/test-skill", "do stuff")
result = build_skill_invocation_message("/test-skill", "do stuff")

assert msg is not None
assert "remote environment" in msg.lower()
assert result.ok
assert result.message is not None
assert "remote environment" in result.message.lower()

def test_supporting_file_hint_uses_file_path_argument(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
Expand All @@ -367,10 +392,11 @@ def test_supporting_file_hint_uses_file_path_argument(self, tmp_path):
references.mkdir()
(references / "api.md").write_text("reference")
scan_skill_commands()
msg = build_skill_invocation_message("/test-skill", "do stuff")
result = build_skill_invocation_message("/test-skill", "do stuff")

assert msg is not None
assert 'file_path="<path>"' in msg
assert result.ok
assert result.message is not None
assert 'file_path="<path>"' in result.message


class TestPlanSkillHelpers:
Expand All @@ -390,7 +416,7 @@ def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path):
body="Save plans under .hermes/plans in the active workspace and do not execute the work.",
)
scan_skill_commands()
msg = build_skill_invocation_message(
result = build_skill_invocation_message(
"/plan",
"Add a /plan command",
runtime_note=(
Expand All @@ -399,9 +425,10 @@ def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path):
),
)

assert msg is not None
assert "Save plans under $HERMES_HOME/plans" not in msg
assert ".hermes/plans" in msg
assert "Add a /plan command" in msg
assert ".hermes/plans/plan.md" in msg
assert "Runtime note:" in msg
assert result.ok
assert result.message is not None
assert "Save plans under $HERMES_HOME/plans" not in result.message
assert ".hermes/plans" in result.message
assert "Add a /plan command" in result.message
assert ".hermes/plans/plan.md" in result.message
assert "Runtime note:" in result.message
Loading