mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
feat(skills): describe a /skill turn the way the user typed it
A /skill invocation expands into a message that embeds the whole skill body. Anything that summarizes a user turn from its raw content reads the skill's prose as if the user had written it. describe_skill_invocation() sits next to the existing extractor and reuses its markers, returning `/work — fix the title leak` for an invocation with an instruction and `/work` for a bare one. It also exports the SQL LIKE pattern and excerpt-joint sentinel that listing queries need to recognize scaffolding before a row reaches Python.
This commit is contained in:
parent
dacd8d5416
commit
e4724ea455
2 changed files with 214 additions and 0 deletions
|
|
@ -54,6 +54,21 @@ _BUNDLE_MARKER = " skill bundle,"
|
|||
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
|
||||
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
|
||||
|
||||
# The skill name sits in the first quoted span of the activation note, for both
|
||||
# the single-skill and the bundle header ("work" / "/clean /work").
|
||||
_SKILL_NAME_RE = re.compile(re.escape(_SKILL_INVOCATION_PREFIX) + r'"([^"]*)"')
|
||||
|
||||
# SQL LIKE pattern matching a skill-expanded turn, for listing queries that
|
||||
# have to recognize scaffolding before the row reaches Python. The prefix
|
||||
# contains no LIKE wildcards (`%`, `_`), so it needs no ESCAPE clause.
|
||||
SKILL_SCAFFOLD_SQL_LIKE = _SKILL_INVOCATION_PREFIX + "%"
|
||||
|
||||
# Marks where a preview query joined the head and tail of a long scaffolded
|
||||
# message. ``describe_skill_invocation`` may hand back a span that runs across
|
||||
# the joint (a bundle instruction cut off by the head window); callers cut the
|
||||
# description there rather than show the skill body on the far side.
|
||||
SKILL_EXCERPT_JOINT = "\x1e"
|
||||
|
||||
|
||||
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
||||
"""Recover the user's instruction from a slash-skill-expanded turn.
|
||||
|
|
@ -82,6 +97,41 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def describe_skill_invocation(content: Any) -> Optional[str]:
|
||||
"""Render a slash-skill-expanded turn the way the user typed it.
|
||||
|
||||
The expanded message embeds the whole skill body, so any surface that
|
||||
summarizes a user turn from its raw content — session titles, sidebar
|
||||
previews, the ``/rewind`` picker — otherwise shows the skill's own prose
|
||||
as if the user had written it. That is how a skill's opening line ends up
|
||||
as a session title.
|
||||
|
||||
Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
|
||||
invocation, or ``None`` when *content* is not skill scaffolding (the
|
||||
caller should then summarize it as an ordinary message).
|
||||
"""
|
||||
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return None
|
||||
|
||||
match = _SKILL_NAME_RE.match(content)
|
||||
name = (match.group(1) if match else "").strip()
|
||||
# Bundle headers already carry their typed "/a /b" keys; a single skill is
|
||||
# a bare name.
|
||||
label = name if name.startswith("/") else f"/{name}"
|
||||
|
||||
instruction = extract_user_instruction_from_skill_message(content)
|
||||
if instruction and instruction is not content:
|
||||
# An excerpted message (head + tail, joined by SKILL_EXCERPT_JOINT) can
|
||||
# put the joint inside the matched span — keep only the side the
|
||||
# instruction marker was found on.
|
||||
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
|
||||
instruction = " ".join(instruction.split())
|
||||
if instruction:
|
||||
return f"{label} — {instruction}" if name else instruction
|
||||
|
||||
return label if name else None
|
||||
|
||||
|
||||
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
|
||||
# Single-skill format appends the user instruction after the skill body, so
|
||||
# the last occurrence is the user-provided one; the body may quote this text.
|
||||
|
|
|
|||
164
tests/agent/test_skill_invocation_description.py
Normal file
164
tests/agent/test_skill_invocation_description.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""describe_skill_invocation() — recovering what the user typed from a /skill turn.
|
||||
|
||||
A /skill invocation expands into a message that embeds the whole skill body. Any
|
||||
surface that summarizes a user turn from its raw content (session titles, sidebar
|
||||
previews, the /rewind picker) otherwise shows the SKILL's prose as if the user had
|
||||
written it.
|
||||
|
||||
Every case here builds the scaffolding with the real message builders rather than
|
||||
a hand-written literal, so the description can't silently drift from the format it
|
||||
parses.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.skill_bundles as skill_bundles
|
||||
import agent.skill_commands as skill_commands
|
||||
import tools.skills_tool as skills_tool
|
||||
from agent.skill_commands import (
|
||||
SKILL_EXCERPT_JOINT,
|
||||
SKILL_SCAFFOLD_SQL_LIKE,
|
||||
describe_skill_invocation,
|
||||
)
|
||||
|
||||
SKILL_BODY = "Kick off a task in a fresh isolated git worktree instead of the current checkout."
|
||||
|
||||
|
||||
def _write_skill(skills_dir, name, body=SKILL_BODY):
|
||||
skill_dir = skills_dir / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
|
||||
def _write_bundle(bundles_dir, slug, skills):
|
||||
bundles_dir.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"name: {slug}", "skills:"]
|
||||
lines.extend(f" - {skill}" for skill in skills)
|
||||
(bundles_dir / f"{slug}.yaml").write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def skills(tmp_path, monkeypatch):
|
||||
"""Install a 'work' skill and a 'demo' bundle, with caches reset."""
|
||||
skills_dir = tmp_path / "skills"
|
||||
bundles_dir = tmp_path / "skill-bundles"
|
||||
_write_skill(skills_dir, "work")
|
||||
_write_skill(skills_dir, "clean", body="Polish your own diff by hand.")
|
||||
_write_bundle(bundles_dir, "demo", ["work", "clean"])
|
||||
|
||||
monkeypatch.setattr(skills_tool, "SKILLS_DIR", skills_dir)
|
||||
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
|
||||
monkeypatch.setattr(skill_commands, "_skill_commands", {})
|
||||
monkeypatch.setattr(skill_commands, "_skill_commands_platform", None)
|
||||
monkeypatch.setattr(skill_bundles, "_bundles_cache", {})
|
||||
monkeypatch.setattr(skill_bundles, "_bundles_cache_mtime", None)
|
||||
skill_commands.scan_skill_commands()
|
||||
skill_bundles.scan_bundles()
|
||||
return skills_dir
|
||||
|
||||
|
||||
class TestDescribeSkillInvocation:
|
||||
def test_passes_through_a_normal_message(self):
|
||||
assert describe_skill_invocation("fix the title leak") is None
|
||||
|
||||
def test_ignores_non_string_content(self):
|
||||
assert describe_skill_invocation(None) is None
|
||||
assert describe_skill_invocation([{"type": "text", "text": "hi"}]) is None
|
||||
|
||||
def test_recovers_the_typed_instruction(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work", user_instruction="fix the title leak"
|
||||
)
|
||||
assert describe_skill_invocation(message) == "/work — fix the title leak"
|
||||
|
||||
def test_bare_invocation_describes_the_skill_alone(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message("/work")
|
||||
assert describe_skill_invocation(message) == "/work"
|
||||
|
||||
def test_never_surfaces_the_skill_body(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work", user_instruction="fix the title leak"
|
||||
)
|
||||
described = describe_skill_invocation(message)
|
||||
assert "worktree" not in described
|
||||
assert "IMPORTANT" not in described
|
||||
|
||||
def test_runtime_note_is_not_part_of_the_instruction(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work",
|
||||
user_instruction="fix the title leak",
|
||||
runtime_note="the runtime detail",
|
||||
)
|
||||
assert describe_skill_invocation(message) == "/work — fix the title leak"
|
||||
|
||||
def test_collapses_whitespace_in_a_multiline_instruction(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work", user_instruction="fix the\n title\tleak"
|
||||
)
|
||||
assert describe_skill_invocation(message) == "/work — fix the title leak"
|
||||
|
||||
def test_bundle_carries_its_typed_keys(self, skills):
|
||||
result = skill_bundles.build_bundle_invocation_message(
|
||||
"/demo", user_instruction="fix the title leak"
|
||||
)
|
||||
assert result is not None
|
||||
message, _, _ = result
|
||||
described = describe_skill_invocation(message)
|
||||
assert described.endswith("— fix the title leak")
|
||||
assert "worktree" not in described
|
||||
|
||||
def test_stacked_skills_describe_every_typed_key(self, skills):
|
||||
result = skill_commands.build_stacked_skill_invocation_message(
|
||||
["/work", "/clean"], user_instruction="fix the title leak"
|
||||
)
|
||||
assert result is not None
|
||||
message, _, _ = result
|
||||
assert describe_skill_invocation(message) == "/work /clean — fix the title leak"
|
||||
|
||||
|
||||
class TestExcerptedScaffolding:
|
||||
"""Preview queries hand over a head+tail excerpt, not the whole message."""
|
||||
|
||||
def _excerpt(self, message, window=400):
|
||||
"""Mirror what _preview_raw_select() hands to _shape_preview()."""
|
||||
flat = message.replace("\n", " ").replace("\r", " ")
|
||||
if len(message) <= window * 2:
|
||||
return flat[: window * 2]
|
||||
return flat[:window] + SKILL_EXCERPT_JOINT + flat[-window:]
|
||||
|
||||
def test_excerpt_still_recovers_the_instruction(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work", user_instruction="fix the title leak"
|
||||
)
|
||||
described = describe_skill_invocation(self._excerpt(message))
|
||||
assert described == "/work — fix the title leak"
|
||||
|
||||
def test_description_never_runs_across_the_joint(self, skills):
|
||||
# A long body pushes the head window into the middle of the skill text;
|
||||
# the instruction is only present on the tail side.
|
||||
skill_md = skills / "work" / "SKILL.md"
|
||||
skill_md.write_text(skill_md.read_text().replace(SKILL_BODY, "filler line.\n" * 200))
|
||||
skill_commands._skill_commands = {}
|
||||
skill_commands._skill_commands_platform = None
|
||||
skill_commands.scan_skill_commands()
|
||||
message = skill_commands.build_skill_invocation_message(
|
||||
"/work", user_instruction="fix the title leak"
|
||||
)
|
||||
described = describe_skill_invocation(self._excerpt(message))
|
||||
assert SKILL_EXCERPT_JOINT not in described
|
||||
assert "filler line" not in described
|
||||
|
||||
|
||||
class TestSqlLikePattern:
|
||||
def test_matches_the_prefix_the_builders_emit(self, skills):
|
||||
message = skill_commands.build_skill_invocation_message("/work")
|
||||
assert message.startswith(SKILL_SCAFFOLD_SQL_LIKE.rstrip("%"))
|
||||
|
||||
def test_carries_no_like_wildcards_needing_escape(self):
|
||||
# The pattern is interpolated into SQL without an ESCAPE clause, so the
|
||||
# literal part must not contain '%' or '_'.
|
||||
assert "%" not in SKILL_SCAFFOLD_SQL_LIKE[:-1]
|
||||
assert "_" not in SKILL_SCAFFOLD_SQL_LIKE[:-1]
|
||||
Loading…
Add table
Add a link
Reference in a new issue