mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 12:06:18 +00:00
* 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>
77 lines
2.7 KiB
Python
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),
|
|
)
|