Undermoon1412 aa7f616734
fix(composer): reserve context slash command alias (#5279)
* fix(composer): reserve context slash command alias

* fix(skills): align slash docs and formatting

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

* fix(skills): allow context skill outside compact alias

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

* docs(tui): sync context skill command policy

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>

---------

Signed-off-by: Undermoon1412 <80385295+Undermoon1412@users.noreply.github.com>
2026-09-18 07:48:24 +08:00

77 lines
2.7 KiB
Python

from __future__ import annotations
import re
from dataclasses import dataclass
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.skills.types import Skill
#: Composer control names that may own the leading slash and must not be
#: treated as ``/skill`` activations when their command syntax matches.
#: These values plus :data:`_SLASH_SKILL_RE` are mirrored by the frontend parser in
#: ``frontend/src/core/skills/slash.ts``; both sides are pinned to the shared
#: fixture at ``contracts/slash_skill_contract.json`` by contract tests
#: (``tests/test_slash_skill_contract.py`` here, ``slash-contract.test.ts`` on
#: the frontend), so a reserved command or grammar change in only one language
#: fails CI.
RESERVED_SLASH_SKILL_NAMES = frozenset({"agent", "bootstrap", "context", "goal", "help", "memory", "models", "new", "status"})
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
_CONTEXT_COMPACT_ARGUMENT = "compact"
@dataclass(frozen=True, slots=True)
class SlashSkillReference:
"""Parsed slash-skill command with the skill name and remaining task text."""
name: str
remaining_text: str
@dataclass(frozen=True, slots=True)
class ResolvedSlashSkill:
"""Slash-skill activation resolved against enabled runtime-visible skills."""
skill: Skill
remaining_text: str
container_file_path: str
def parse_slash_skill_reference(text: str) -> SlashSkillReference | None:
"""Parse strict `/skill-name task` syntax, ignoring reserved control commands."""
match = _SLASH_SKILL_RE.match(text)
if not match:
return None
name = match.group(1)
remaining_text = text[match.end() :].lstrip()
if name in RESERVED_SLASH_SKILL_NAMES and not (name == "context" and remaining_text.strip().casefold() != _CONTEXT_COMPACT_ARGUMENT):
return None
return SlashSkillReference(
name=name,
remaining_text=remaining_text,
)
def resolve_slash_skill(
text: str,
skills: list[Skill],
*,
available_skills: set[str] | None = None,
container_base_path: str = DEFAULT_SKILLS_CONTAINER_PATH,
) -> ResolvedSlashSkill | None:
"""Resolve text into an enabled, whitelisted skill activation if possible."""
reference = parse_slash_skill_reference(text)
if reference is None:
return None
if available_skills is not None and reference.name not in available_skills:
return None
skill = next((candidate for candidate in skills if candidate.name == reference.name and candidate.enabled), None)
if skill is None:
return None
return ResolvedSlashSkill(
skill=skill,
remaining_text=reference.remaining_text,
container_file_path=skill.get_container_file_path(container_base_path),
)