feat(skill): align skill system with Claude Code's Tool Call design

- Replace text marker activation with `activate` tool (Tool Call mechanism)
- Replace 7 authoring tools with 2: `activate` + `register_skill`
- Add builtin skills loading from templates/skills/
- Add create-skill as first builtin skill
- Remove SKILL_ACTIVATION_MARKER and text detection methods
- Tool Result returns SKILL.md content (protects KV Cache)

This aligns with Claude Code's progressive disclosure pattern:
- Metadata (name+description) always visible in tool description
- SKILL.md body loaded on activate via Tool Call
- Bundled resources accessible through virtual path mapping

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-05-13 21:15:39 +08:00
parent 4db0f20dc4
commit 7145447bcb
6 changed files with 364 additions and 738 deletions
+58 -156
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import datetime as dt
import os
import re
import typing
from ..core import app
@@ -14,9 +13,16 @@ if typing.TYPE_CHECKING:
class SkillManager:
"""Skill manager backed purely by filesystem packages under data/skills."""
"""Skill manager backed by filesystem packages.
SKILL_ACTIVATION_MARKER = '[ACTIVATE_SKILL:'
Skills are loaded from two sources:
1. Builtin skills: templates/skills/ (shipped with LangBot)
2. User skills: data/skills/ (created by users)
Skills are activated through the `activate` tool (Tool Call mechanism),
aligned with Claude Code's design. This protects KV Cache and follows
industry standard.
"""
ap: app.Application
skills: dict[str, dict]
@@ -29,33 +35,52 @@ class SkillManager:
await self.reload_skills()
async def reload_skills(self):
"""Reload all skills from builtin and user directories.
NOTE: This performs a full scan of both directories. For registering a single
new skill, consider adding it directly to self.skills instead of reloading all.
Current implementation is acceptable for typical skill counts (<50).
"""
self.skills = {}
skills_root = self.get_managed_skills_root()
if not os.path.isdir(skills_root):
self.ap.logger.info('Loaded 0 skills')
return
# Load builtin skills first (templates/skills/)
builtin_root = self.get_builtin_skills_root()
if os.path.isdir(builtin_root):
for package_root, entry_file in self._discover_skill_directories(builtin_root):
skill_data = {
'package_root': package_root,
'entry_file': entry_file,
}
if not self._load_skill_file(skill_data):
continue
for package_root, entry_file in self._discover_skill_directories(skills_root):
skill_data = {
'package_root': package_root,
'entry_file': entry_file,
}
if not self._load_skill_file(skill_data):
continue
skill_name = skill_data['name']
self.skills[skill_name] = skill_data
skill_name = skill_data['name']
if skill_name in self.skills:
self.ap.logger.warning(
f'Duplicate skill name "{skill_name}" found at {package_root}, skipping later entry'
)
continue
# Load user skills (data/skills/) - can override builtin if same name
managed_root = self.get_managed_skills_root()
if os.path.isdir(managed_root):
for package_root, entry_file in self._discover_skill_directories(managed_root):
skill_data = {
'package_root': package_root,
'entry_file': entry_file,
}
if not self._load_skill_file(skill_data):
continue
self.skills[skill_name] = skill_data
skill_name = skill_data['name']
if skill_name in self.skills:
self.ap.logger.warning(
f'User skill "{skill_name}" overrides builtin skill'
)
self.skills[skill_name] = skill_data
self.ap.logger.info(f'Loaded {len(self.skills)} skills')
builtin_count = sum(1 for s in self.skills.values() if s.get('package_root', '').startswith(builtin_root))
user_count = len(self.skills) - builtin_count
self.ap.logger.info(f'Loaded {len(self.skills)} skills ({builtin_count} builtin, {user_count} user)')
def refresh_skill_from_disk(self, skill_name: str) -> bool:
"""Refresh a single skill from disk."""
if not skill_name:
return False
@@ -71,9 +96,16 @@ class SkillManager:
@staticmethod
def get_managed_skills_root() -> str:
"""Get the root directory for managed user skills."""
return paths.get_data_path('skills')
@staticmethod
def get_builtin_skills_root() -> str:
"""Get the root directory for builtin skills (templates/skills/)."""
return paths.get_resource_path('templates/skills')
def _discover_skill_directories(self, root_path: str, max_depth: int = 6) -> list[tuple[str, str]]:
"""Discover all skill directories under root_path."""
discovered: list[tuple[str, str]] = []
root_path = os.path.realpath(os.path.abspath(root_path))
root_depth = root_path.rstrip(os.sep).count(os.sep)
@@ -95,12 +127,14 @@ class SkillManager:
@staticmethod
def _find_skill_entry(path: str) -> tuple[str, str] | None:
"""Find SKILL.md entry file in a directory."""
for candidate in ('SKILL.md', 'skill.md'):
if os.path.isfile(os.path.join(path, candidate)):
return path, candidate
return None
def _load_skill_file(self, skill_data: dict) -> bool:
"""Load skill data from SKILL.md file."""
package_root = self._normalize_package_root(skill_data.get('package_root', ''))
entry_file = skill_data.get('entry_file', 'SKILL.md')
if not package_root:
@@ -148,137 +182,5 @@ class SkillManager:
return os.path.realpath(os.path.abspath(package_root))
def get_skill_by_name(self, name: str) -> dict | None:
return self.skills.get(name)
def get_skill_index(self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None) -> str:
skills_to_index = []
for skill in self.skills.values():
if bound_skills is not None and skill['name'] not in bound_skills:
continue
skills_to_index.append(skill)
if not skills_to_index:
return ''
lines = ['Available Skills:']
for skill in skills_to_index:
display = skill.get('display_name') or skill['name']
lines.append(f'- {skill["name"]} ({display}): {skill.get("description", "")}')
return '\n'.join(lines)
def build_skill_aware_prompt_addition(
self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None
) -> str:
skill_index = self.get_skill_index(pipeline_uuid, bound_skills)
if not skill_index:
return ''
return f"""
{skill_index}
When the user's request clearly matches one or more skills based on their descriptions, you should activate them.
To activate a skill, include this marker at the beginning of your response: [ACTIVATE_SKILL: skill-name]
If multiple skills are needed, include multiple activation markers at the beginning of your response, one per line.
After activation, the selected skills' detailed instructions will be loaded for you to follow.
Use the first activated skill as the primary skill. Use any additional activated skills as supporting guidance.
If you need to inspect a visible skill before activation, use `read` on `/workspace/.skills/<skill-name>/SKILL.md` or other files under that path.
For prompt-only skills or skills that mainly consist of instructions, use `create_skill` to create them directly in the managed skills store.
Use `list_skills` or `get_skill` before editing when you need to inspect what already exists.
Use `update_skill` to modify an existing managed skill's metadata or instructions without relying on `/workspace`, box, or skill activation.
Use `delete_skill` when the user explicitly wants to remove a managed skill.
Pure prompt skills should not depend on box just to be created or modified later.
When creating a new skill package with extra files, scripts, or assets, first prepare it under `/workspace` with the standard `exec`, `read`, `write`, and `edit` tools.
Then use `import_skill_from_directory` to import that prepared directory into the managed skills store.
Use `reload_skills` when you need LangBot to rescan managed skills after filesystem changes.
If no skill matches, respond normally without activation.
"""
def detect_skill_activations(self, response: str) -> list[str]:
if self.SKILL_ACTIVATION_MARKER not in response:
return []
activated: list[str] = []
for skill_name in re.findall(r'\[ACTIVATE_SKILL:\s*(\S+?)\s*\]', response):
if skill_name in self.skills and skill_name not in activated:
activated.append(skill_name)
return activated
def detect_skill_activation(self, response: str) -> str | None:
activations = self.detect_skill_activations(response)
return activations[0] if activations else None
def get_skill_runtime_data(self, skill_name: str) -> dict | None:
skill = self.skills.get(skill_name)
if not skill:
return None
return {'skill': skill, 'instructions': skill.get('instructions', '')}
def build_activation_prompt(self, skill_name: str) -> str:
resolved = self.get_skill_runtime_data(skill_name)
if not resolved:
return ''
instructions = resolved['instructions']
return f"""
<activated_skill name=\"{skill_name}\">
## Instructions
{instructions}
## Runtime Context
The activated skill package is available through the standard runtime tools under `/workspace/.skills/{skill_name}`.
Use `read` to inspect files there. Use `exec` with `workdir` set to `/workspace/.skills/{skill_name}` to run commands in that package.
Use `write` and `edit` on that path when the instructions require updating files.
Do not create a new skill by writing directly into `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `update_skill` to change an existing managed skill, `list_skills` or `get_skill` to inspect managed skills, or prepare the new skill under `/workspace` and import it with `import_skill_from_directory`.
</activated_skill>
Now execute the above skill instructions step by step to complete the user's request.
Use the standard `exec`, `read`, `write`, and `edit` tools against `/workspace/.skills/{skill_name}` when you need to inspect or modify the skill package.
Respond to the user based on the skill's guidance.
"""
def build_activation_prompt_for_skills(self, skill_names: list[str]) -> str:
if not skill_names:
return ''
activated_skill_names: list[str] = []
for skill_name in skill_names:
if skill_name in self.skills and skill_name not in activated_skill_names:
activated_skill_names.append(skill_name)
if not activated_skill_names:
return ''
blocks: list[str] = []
for skill_name in activated_skill_names:
resolved = self.get_skill_runtime_data(skill_name)
if not resolved:
continue
instructions = resolved['instructions']
role = 'primary' if skill_name == activated_skill_names[0] else 'auxiliary'
blocks.append(
f"""
<activated_skill name=\"{skill_name}\" role=\"{role}\">\n\n## Instructions\n{instructions}\n\n## Runtime Context\nUse the standard `exec`, `read`, `write`, and `edit` tools for activated skills.\nEach activated skill package is available under `/workspace/.skills/<skill-name>`.\nFor a given skill, set `exec.workdir` to `/workspace/.skills/<skill-name>` and use that prefix in file tool paths.\nDo not create a new skill under `/workspace/.skills/...`; use `create_skill` for prompt-only skills, `list_skills` or `get_skill` to inspect managed skills, `update_skill` to change an existing managed skill, or prepare new skill directories under `/workspace` and import them with `import_skill_from_directory`.\n\n</activated_skill>
""".strip()
)
if not blocks:
return ''
activated_list = ', '.join(activated_skill_names)
return f"""
Activated skills: {activated_list}
{chr(10).join(blocks)}
Now execute the activated skills to complete the user's request.
Treat the first activated skill as the primary skill.
Treat additional activated skills as supporting guidance when they do not conflict with the primary skill.
If guidance conflicts, prefer: primary skill > auxiliary skills.
Use the standard `exec`, `read`, `write`, and `edit` tools against the corresponding `/workspace/.skills/<skill-name>` path whenever you need to inspect or modify an activated skill package.
Respond to the user with one coherent answer that integrates the activated skills.
"""
@staticmethod
def remove_activation_marker(response: str) -> str:
return re.sub(r'\[ACTIVATE_SKILL:\s*\S+?\s*\]\s*', '', response).lstrip()
"""Get skill data by name."""
return self.skills.get(name)