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

View File

@@ -248,7 +248,9 @@ class PreProcessor(stage.PipelineStage):
query.prompt.messages = event_ctx.event.default_prompt
query.messages = event_ctx.event.prompt
# =========== Inject skill index into system prompt ===========
# =========== Store bound skills for runtime visibility checks ===========
# Skills are now activated through the `activate` tool (Tool Call mechanism),
# not through system prompt injection. This aligns with Claude Code's design.
if selected_runner == 'local-agent' and self.ap.skill_mgr:
# Get bound skills from pipeline extensions_preferences
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
@@ -264,42 +266,12 @@ class PreProcessor(stage.PipelineStage):
# Store bound skills in query variables for runtime path visibility checks
query.variables['_pipeline_bound_skills'] = bound_skills
# Build skill awareness addition
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
pipeline_uuid=query.pipeline_uuid,
bound_skills=bound_skills,
)
if skill_addition:
self.ap.logger.info(
f'Skill index injected into system prompt: '
f'pipeline={query.pipeline_uuid} '
f'bound_skills={bound_skills or "all"} '
f'available_skills=[{", ".join(s["name"] for s in self.ap.skill_mgr.skills.values() if bound_skills is None or s["name"] in bound_skills)}]'
)
# Append skill instruction to the first system message
if query.prompt.messages and query.prompt.messages[0].role == 'system':
if isinstance(query.prompt.messages[0].content, str):
query.prompt.messages[0].content += skill_addition
elif isinstance(query.prompt.messages[0].content, list):
# Handle content as list of ContentElements
for ce in query.prompt.messages[0].content:
if ce.type == 'text':
ce.text += skill_addition
break
else:
# Insert a new system message with skill instructions
query.prompt.messages.insert(
0,
provider_message.Message(role='system', content=skill_addition.strip()),
)
else:
loaded_count = len(self.ap.skill_mgr.skills)
self.ap.logger.debug(
f'No skills available for injection: '
f'Skills available for activate tool: '
f'pipeline={query.pipeline_uuid} '
f'loaded_skills={loaded_count} '
f'bound_skills={bound_skills}'
f'bound_skills={bound_skills or "all"}'
)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)

View File

@@ -9,7 +9,6 @@ from ..tools.loaders.native import EXEC_TOOL_NAME
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.rag.context as rag_context
from ...skill.activation import get_skill_activation_coordinator
rag_combined_prompt_template = """
@@ -160,7 +159,6 @@ class LocalAgentRunner(runner.RequestRunner):
"""Run request"""
pending_tool_calls = []
initial_response_emitted = False
skill_activation = get_skill_activation_coordinator(self.ap)
# Get knowledge bases list from query variables (set by PreProcessor,
# may have been modified by plugins during PromptPreProcessing)
@@ -302,7 +300,6 @@ class LocalAgentRunner(runner.RequestRunner):
accumulated_content = ''
last_role = 'assistant'
msg_sequence = 1
suppress_initial_stream = False
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
query,
@@ -333,31 +330,7 @@ class LocalAgentRunner(runner.RequestRunner):
if tool_call.function and tool_call.function.arguments:
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
emitted_this_round = False
if skill_activation is not None:
activation_prefix_state = skill_activation.inspect_initial_content(
accumulated_content,
msg.is_final,
)
if activation_prefix_state == 'buffer':
suppress_initial_stream = True
elif (
activation_prefix_state == 'emit'
and suppress_initial_stream is False
and not initial_response_emitted
):
msg_sequence += 1
yield provider_message.MessageChunk(
role=last_role,
content=accumulated_content,
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
is_final=msg.is_final,
msg_sequence=msg_sequence,
)
initial_response_emitted = True
emitted_this_round = True
if not suppress_initial_stream and not emitted_this_round and (msg_idx % 8 == 0 or msg.is_final):
if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1
yield provider_message.MessageChunk(
role=last_role,
@@ -380,111 +353,6 @@ class LocalAgentRunner(runner.RequestRunner):
if isinstance(final_msg, provider_message.MessageChunk):
first_end_sequence = final_msg.msg_sequence
# =========== Skill activation detection ===========
# Check if the LLM response contains a skill activation marker
if first_content and skill_activation is not None:
activation_plan = None
original_req_messages_len = len(req_messages)
try:
activation_plan = skill_activation.prepare_followup(query, first_content)
if activation_plan:
self.ap.logger.info(f'Skill activations detected: {activation_plan.activated_skill_names}')
# Reconstruct messages with a sanitized activation response, then add the skill prompt.
sanitized_activation_msg = provider_message.Message(
role=getattr(final_msg, 'role', 'assistant'),
content=activation_plan.cleaned_content,
tool_calls=getattr(final_msg, 'tool_calls', None),
)
req_messages.append(sanitized_activation_msg)
req_messages.append(activation_plan.system_message)
# Make another request to let the LLM execute the skill
if is_stream:
tool_calls_map = {}
msg_idx = 0
accumulated_content = ''
last_role = 'assistant'
msg_sequence = first_end_sequence
async for msg in use_llm_model.provider.invoke_llm_stream(
query,
use_llm_model,
req_messages,
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think,
):
msg_idx += 1
if msg.role:
last_role = msg.role
if msg.content:
accumulated_content += msg.content
if msg.tool_calls:
for tool_call in msg.tool_calls:
if tool_call.id not in tool_calls_map:
tool_calls_map[tool_call.id] = provider_message.ToolCall(
id=tool_call.id,
type=tool_call.type,
function=provider_message.FunctionCall(
name=tool_call.function.name if tool_call.function else '',
arguments='',
),
)
if tool_call.function and tool_call.function.arguments:
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
if msg_idx % 8 == 0 or msg.is_final:
msg_sequence += 1
yield provider_message.MessageChunk(
role=last_role,
content=accumulated_content,
tool_calls=list(tool_calls_map.values())
if (tool_calls_map and msg.is_final)
else None,
is_final=msg.is_final,
msg_sequence=msg_sequence,
)
initial_response_emitted = True
final_msg = provider_message.MessageChunk(
role=last_role,
content=accumulated_content,
tool_calls=list(tool_calls_map.values()) if tool_calls_map else None,
msg_sequence=msg_sequence,
)
first_content = accumulated_content
first_end_sequence = msg_sequence
else:
msg = await use_llm_model.provider.invoke_llm(
query,
use_llm_model,
req_messages,
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
extra_args=use_llm_model.model_entity.extra_args,
remove_think=remove_think,
)
final_msg = msg
first_content = msg.content
# Update pending tool calls from the new response
pending_tool_calls = final_msg.tool_calls
# Remove the sanitized activation message and follow-up system prompt.
req_messages = req_messages[:-2]
except Exception:
self.ap.logger.exception('Skill activation failed, falling back to normal execution')
skill_activation.rollback(
query,
activation_plan.snapshot if activation_plan is not None else None,
final_msg,
)
req_messages = req_messages[:original_req_messages_len]
first_content = final_msg.content
if not is_stream:
yield final_msg
initial_response_emitted = True

View File

@@ -7,34 +7,22 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from .. import loader
# Skill authoring needs a managed abstraction above the generic box tools.
# Pure prompt skills are just metadata plus SKILL.md instructions, so creating
# or updating them should not require /workspace mounts, shell access, or box
# to be enabled at all. These higher-level tools let local agents manage skills
# directly through SkillService, while import_skill_from_directory remains the
# path for file-based skills that actually need scripts or assets from box.
# Align with Claude Code's Skill tool design:
# - activate: Activate a skill via Tool Call, returns SKILL.md content
# - register_skill: Register a skill from sandbox directory to data/skills/
# - This protects KV Cache and follows industry standard
CREATE_SKILL_TOOL_NAME = 'create_skill'
LIST_SKILLS_TOOL_NAME = 'list_skills'
GET_SKILL_TOOL_NAME = 'get_skill'
UPDATE_SKILL_TOOL_NAME = 'update_skill'
DELETE_SKILL_TOOL_NAME = 'delete_skill'
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME = 'import_skill_from_directory'
RELOAD_SKILLS_TOOL_NAME = 'reload_skills'
ACTIVATE_SKILL_TOOL_NAME = 'activate'
REGISTER_SKILL_TOOL_NAME = 'register_skill'
AUTHORING_TOOL_NAMES = {
CREATE_SKILL_TOOL_NAME,
LIST_SKILLS_TOOL_NAME,
GET_SKILL_TOOL_NAME,
UPDATE_SKILL_TOOL_NAME,
DELETE_SKILL_TOOL_NAME,
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
RELOAD_SKILLS_TOOL_NAME,
SKILL_TOOL_NAMES = {
ACTIVATE_SKILL_TOOL_NAME,
REGISTER_SKILL_TOOL_NAME,
}
class SkillAuthoringToolLoader(loader.ToolLoader):
"""Minimal system actions for filesystem-backed skills."""
class SkillToolLoader(loader.ToolLoader):
"""Skill tools aligned with Claude Code's design."""
def __init__(self, ap):
super().__init__(ap)
@@ -42,148 +30,130 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
async def initialize(self):
self._tools = [
self._build_create_skill_tool(),
self._build_list_skills_tool(),
self._build_get_skill_tool(),
self._build_update_skill_tool(),
self._build_delete_skill_tool(),
self._build_import_skill_from_directory_tool(),
self._build_reload_skills_tool(),
self._build_activate_skill_tool(),
self._build_register_skill_tool(),
]
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
if not self._has_authoring_services():
if not self._has_skill_manager():
return []
return list(self._tools)
async def has_tool(self, name: str) -> bool:
return self._has_authoring_services() and name in AUTHORING_TOOL_NAMES
return self._has_skill_manager() and name in SKILL_TOOL_NAMES
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
if name == CREATE_SKILL_TOOL_NAME:
return await self._invoke_create_skill(parameters)
if name == LIST_SKILLS_TOOL_NAME:
return await self._invoke_list_skills()
if name == GET_SKILL_TOOL_NAME:
return await self._invoke_get_skill(parameters)
if name == UPDATE_SKILL_TOOL_NAME:
return await self._invoke_update_skill(parameters)
if name == DELETE_SKILL_TOOL_NAME:
return await self._invoke_delete_skill(parameters)
if name == IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME:
return await self._invoke_import_skill_from_directory(parameters)
if name == RELOAD_SKILLS_TOOL_NAME:
return await self._invoke_reload_skills()
raise ValueError(f'Unknown skill authoring tool: {name}')
if name == ACTIVATE_SKILL_TOOL_NAME:
return await self._invoke_activate_skill(parameters, query)
if name == REGISTER_SKILL_TOOL_NAME:
return await self._invoke_register_skill(parameters)
raise ValueError(f'Unknown skill tool: {name}')
async def shutdown(self):
pass
def _has_authoring_services(self) -> bool:
return getattr(self.ap, 'skill_service', None) is not None
def _has_skill_manager(self) -> bool:
return getattr(self.ap, 'skill_mgr', None) is not None
async def _invoke_reload_skills(self) -> typing.Any:
await self.ap.skill_service.reload_skills()
skills = await self.ap.skill_service.list_skills()
return {
'reloaded': True,
'skill_names': [skill['name'] for skill in skills],
'count': len(skills),
}
async def _invoke_activate_skill(self, parameters: dict, query) -> typing.Any:
"""Activate a skill and return SKILL.md content via Tool Result."""
skill_name = str(parameters.get('skill_name', '') or '').strip()
if not skill_name:
raise ValueError('skill_name is required')
async def _invoke_create_skill(self, parameters: dict) -> typing.Any:
name = str(parameters.get('name', '') or '').strip()
instructions = str(parameters.get('instructions', '') or '')
if not name:
raise ValueError('name is required')
if not instructions.strip():
raise ValueError('instructions is required')
created = await self.ap.skill_service.create_skill(
{
'name': name,
'display_name': str(parameters.get('display_name', '') or '').strip(),
'description': str(parameters.get('description', '') or '').strip(),
'instructions': instructions,
}
skill_mgr = self.ap.skill_mgr
skill_data = skill_mgr.get_skill_by_name(skill_name)
if skill_data is None:
visible_skills = getattr(skill_mgr, 'skills', {})
available_names = ', '.join(sorted(visible_skills.keys())) or 'none'
raise ValueError(
f'Skill "{skill_name}" not found. Available skills: {available_names}'
)
# Register activated skill for sandbox mount path resolution
from . import skill as skill_loader
skill_loader.register_activated_skill(query, skill_data)
# Return SKILL.md content as Tool Result (injects into context)
instructions = skill_data.get('instructions', '')
package_root = skill_data.get('package_root', '')
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name)
# Build Tool Result content
result_content = f'<command-message>The "{skill_name}" skill is activated</command-message>\n'
result_content += f'<skill-activation>\n'
result_content += f'<skill-name>{skill_name}</skill-name>\n'
result_content += f'<mount-path>{mount_path}</mount-path>\n'
result_content += f'<package-root>{package_root}</package-root>\n'
result_content += f'\n## Instructions\n{instructions}\n'
result_content += f'\n## Runtime Context\n'
result_content += f'The skill package is mounted at {mount_path}. Use the standard tools to interact with it:\n'
result_content += f'- Use `read` to inspect files under {mount_path}\n'
result_content += f'- Use `exec` with workdir set to {mount_path} to run commands in that package\n'
result_content += f'- Use `write` and `edit` on that path when the instructions require updating files\n'
result_content += f'</skill-activation>\n'
return {
'created': True,
'skill': created,
'activated': True,
'skill_name': skill_name,
'mount_path': mount_path,
'content': result_content,
}
async def _invoke_list_skills(self) -> typing.Any:
skills = await self.ap.skill_service.list_skills()
return {
'skills': skills,
'skill_names': [skill['name'] for skill in skills],
'count': len(skills),
}
async def _invoke_get_skill(self, parameters: dict) -> typing.Any:
name = str(parameters.get('name', '') or '').strip()
if not name:
raise ValueError('name is required')
skill = await self.ap.skill_service.get_skill(name)
if not skill:
raise ValueError(f'Skill "{name}" not found')
return {'skill': skill}
async def _invoke_update_skill(self, parameters: dict) -> typing.Any:
name = str(parameters.get('name', '') or '').strip()
if not name:
raise ValueError('name is required')
data = {'name': name}
for field in ('display_name', 'description', 'instructions'):
if field in parameters:
data[field] = parameters[field]
updated = await self.ap.skill_service.update_skill(name, data)
return {
'updated': True,
'skill': updated,
}
async def _invoke_delete_skill(self, parameters: dict) -> typing.Any:
name = str(parameters.get('name', '') or '').strip()
if not name:
raise ValueError('name is required')
await self.ap.skill_service.delete_skill(name)
return {
'deleted': True,
'skill_name': name,
}
async def _invoke_import_skill_from_directory(self, parameters: dict) -> typing.Any:
async def _invoke_register_skill(self, parameters: dict) -> typing.Any:
"""Register a skill from sandbox directory to data/skills/."""
sandbox_path = str(parameters.get('path', '') or '').strip()
if not sandbox_path:
raise ValueError('path is required')
# Resolve sandbox path to host path
host_path = self._resolve_workspace_directory(sandbox_path)
scanned = self.ap.skill_service.scan_directory(host_path)
created = await self.ap.skill_service.create_skill(
# Verify SKILL.md exists
skill_md_path = os.path.join(host_path, 'SKILL.md')
if not os.path.isfile(skill_md_path):
# Try skill.md as alternative
skill_md_path = os.path.join(host_path, 'skill.md')
if not os.path.isfile(skill_md_path):
raise ValueError(f'SKILL.md not found in directory: {sandbox_path}')
# Get or create skill service
skill_service = getattr(self.ap, 'skill_service', None)
if skill_service is None:
raise ValueError('Skill service not available')
# Scan and register the skill
scanned = skill_service.scan_directory(host_path)
# Override name if provided
skill_name = str(parameters.get('name') or scanned['name']).strip()
if not skill_name:
raise ValueError('skill name is required')
# Create the skill
created = await skill_service.create_skill(
{
'name': str(parameters.get('name') or scanned['name']).strip(),
'name': skill_name,
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
'package_root': host_path,
}
)
return {
'imported': True,
'registered': True,
'skill_name': skill_name,
'source_path': sandbox_path,
'skill': created,
}
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
"""Resolve sandbox path to host filesystem path."""
box_service = getattr(self.ap, 'box_service', None)
workspace_root = getattr(box_service, 'default_workspace', None)
if not workspace_root:
raise ValueError('No default workspace configured for importing skills')
raise ValueError('No default workspace configured')
normalized_path = str(sandbox_path).strip() or '/workspace'
if not normalized_path.startswith('/workspace'):
@@ -192,154 +162,55 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
relative = normalized_path[len('/workspace'):].lstrip('/')
host_root = os.path.realpath(workspace_root)
host_path = os.path.realpath(os.path.join(host_root, relative))
# Security check: ensure path doesn't escape workspace
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
raise ValueError('path escapes the workspace boundary')
if not os.path.isdir(host_path):
raise ValueError(f'Directory does not exist: {sandbox_path}')
return host_path
def _build_create_skill_tool(self) -> resource_tool.LLMTool:
def _build_activate_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=CREATE_SKILL_TOOL_NAME,
human_desc='Create a managed skill',
name=ACTIVATE_SKILL_TOOL_NAME,
human_desc='Activate a skill',
description=self._build_activate_tool_description(),
parameters={
'type': 'object',
'properties': {
'skill_name': {
'type': 'string',
'description': 'The skill name to activate (no arguments). E.g., "pdf" or "create-skill"',
},
},
'required': ['skill_name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_register_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=REGISTER_SKILL_TOOL_NAME,
human_desc='Register a skill from sandbox',
description=(
'Create a new managed skill directly in the skills store without using /workspace. '
'Use this for prompt-only skills or simple skills whose main content is the SKILL.md instructions. '
'Pure prompt skills should not depend on box or a workspace directory just to be created or edited later.'
),
parameters={
'type': 'object',
'properties': {
'name': {
'type': 'string',
'description': 'Skill name. Use lowercase letters, numbers, hyphens, or underscores.',
},
'display_name': {
'type': 'string',
'description': 'Optional human-friendly display name.',
},
'description': {
'type': 'string',
'description': 'Optional concise description of what the skill does and when to use it.',
},
'instructions': {
'type': 'string',
'description': 'The SKILL.md body instructions for the new skill.',
},
},
'required': ['name', 'instructions'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_list_skills_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=LIST_SKILLS_TOOL_NAME,
human_desc='List managed skills',
description='List all managed skills so you can inspect what already exists before creating, updating, or deleting one.',
parameters={
'type': 'object',
'properties': {},
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_get_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=GET_SKILL_TOOL_NAME,
human_desc='Get a managed skill',
description='Fetch one managed skill by name, including its current metadata and instructions, without relying on /workspace or skill activation.',
parameters={
'type': 'object',
'properties': {
'name': {
'type': 'string',
'description': 'Existing skill name to fetch.',
},
},
'required': ['name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_update_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=UPDATE_SKILL_TOOL_NAME,
human_desc='Update a managed skill',
description=(
'Update an existing managed skill directly in the skills store without using /workspace. '
'Use this for prompt-only skills or for metadata and instruction changes to an existing skill. '
'Pure prompt skills should remain editable through managed skill tools instead of depending on box.'
),
parameters={
'type': 'object',
'properties': {
'name': {
'type': 'string',
'description': 'Existing skill name to update.',
},
'display_name': {
'type': 'string',
'description': 'Optional new human-friendly display name.',
},
'description': {
'type': 'string',
'description': 'Optional new concise description.',
},
'instructions': {
'type': 'string',
'description': 'Optional replacement SKILL.md body instructions.',
},
},
'required': ['name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_delete_skill_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=DELETE_SKILL_TOOL_NAME,
human_desc='Delete a managed skill',
description='Delete an existing managed skill by name from the managed skills store.',
parameters={
'type': 'object',
'properties': {
'name': {
'type': 'string',
'description': 'Existing skill name to delete.',
},
},
'required': ['name'],
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_import_skill_from_directory_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
human_desc='Import skill from workspace directory',
description=(
'Import a skill package from a directory under /workspace into the managed skills store. '
'Use this after cloning or preparing a skill repository in the default workspace. '
'This is for file-based skills that actually need scripts, assets, or extra files. '
'Pure prompt skills should use create_skill or update_skill instead of depending on box. '
'If the source directory is already under the managed skills root, it will be registered in place instead of copied again.'
'Register a skill package from a directory under /workspace into LangBot\'s skill store. '
'Use this after creating or preparing a skill in the sandbox with exec/read/write/edit. '
'The directory must contain a SKILL.md file. '
'After registration, the skill can be activated with the activate tool.'
),
parameters={
'type': 'object',
'properties': {
'path': {
'type': 'string',
'description': 'Directory path under /workspace that contains a skill package or a nested SKILL.md.',
'description': 'Directory path under /workspace containing the skill package (must have SKILL.md)',
},
'name': {
'type': 'string',
'description': 'Optional skill name override. Defaults to the scanned skill name.',
'description': 'Optional skill name override. Defaults to the name in SKILL.md or directory name.',
},
'display_name': {
'type': 'string',
@@ -360,18 +231,50 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
func=lambda parameters: parameters,
)
def _build_reload_skills_tool(self) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=RELOAD_SKILLS_TOOL_NAME,
human_desc='Reload filesystem skills',
description=(
'Reload skills from the filesystem after using the standard exec/read/write/edit tools '
'to create, rename, or modify skill packages under the managed skills directory.'
),
parameters={
'type': 'object',
'properties': {},
'additionalProperties': False,
},
func=lambda parameters: parameters,
)
def _build_activate_tool_description(self) -> str:
"""Build tool description with embedded available_skills list."""
skill_mgr = getattr(self.ap, 'skill_mgr', None)
if skill_mgr is None:
return 'Activate a skill. No skills are currently available.'
skills = getattr(skill_mgr, 'skills', {})
if not skills:
return 'Activate a skill. No skills are currently available.'
# Build <available_skills> section
available_skills_lines = ['<available_skills>']
for skill_name, skill_data in sorted(skills.items()):
display_name = skill_data.get('display_name') or skill_name
description = skill_data.get('description', '')
available_skills_lines.append(f'<skill>')
available_skills_lines.append(f'<name>{skill_name}</name>')
available_skills_lines.append(f'<description>{description}</description>')
available_skills_lines.append(f'</skill>')
available_skills_lines.append('</available_skills>')
available_skills_block = '\n'.join(available_skills_lines)
return f'''Activate a skill within the main conversation.
<skills_instructions>
When users ask you to perform tasks, check if any of the available skills
below can help complete the task more effectively. Skills provide specialized
capabilities and domain knowledge.
How to use skills:
- Invoke skills using this tool with the skill name only (no arguments)
- When you invoke a skill, you will see <command-message>
The skill is activated
</command-message>
- The skill's instructions will be provided in the tool result
- Examples:
- skill_name: "pdf" - invoke the pdf skill
- skill_name: "create-skill" - invoke the create-skill skill for creating new skills
Important:
- Only use skills listed in <available_skills> below
- Do not invoke a skill that is already running
- To create a new skill: prepare it in /workspace, then use register_skill tool
</skills_instructions>
{available_skills_block}'''

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import typing
import langbot_plugin.api.entities.builtin.provider.message as provider_message
@@ -13,147 +11,26 @@ if typing.TYPE_CHECKING:
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@dataclass
class PreparedSkillActivation:
activated_skill_names: list[str]
cleaned_content: str
prompt: str
# Skill activation is now handled through Tool Call mechanism (activate tool).
# This file is kept for potential future extensions but the text marker
# detection mechanism has been removed.
@dataclass
class SkillActivationSnapshot:
use_funcs: list | None
variables: dict | None
@dataclass
class SkillActivationPlan:
activated_skill_names: list[str]
cleaned_content: str
system_message: provider_message.Message
snapshot: SkillActivationSnapshot
class SkillActivationCoordinator:
"""Owns the skill activation protocol around the local-agent runner."""
def __init__(self, ap: app.Application, skill_mgr: typing.Any):
self.ap = ap
self.skill_mgr = skill_mgr
def inspect_initial_content(self, content: str | None, is_final: bool) -> str:
if not content:
return 'emit'
stripped = content.lstrip()
if not stripped:
return 'undecided'
marker = str(getattr(self.skill_mgr, 'SKILL_ACTIVATION_MARKER', '[ACTIVATE_SKILL:'))
if stripped.startswith(marker):
return 'buffer'
if not is_final and marker.startswith(stripped):
return 'undecided'
return 'emit'
def prepare_followup(
self,
query: pipeline_query.Query,
response_content: str | None,
) -> SkillActivationPlan | None:
snapshot = self._snapshot_query_state(query)
try:
activation = prepare_skill_activation(self.ap, query, response_content)
except Exception:
self._restore_query_state(query, snapshot)
raise
if not activation:
return None
return SkillActivationPlan(
activated_skill_names=activation.activated_skill_names,
cleaned_content=activation.cleaned_content,
system_message=provider_message.Message(role='system', content=activation.prompt),
snapshot=snapshot,
)
def rollback(
self,
query: pipeline_query.Query,
snapshot: SkillActivationSnapshot | None,
response_message: provider_message.Message | provider_message.MessageChunk | None,
) -> None:
if snapshot is not None:
self._restore_query_state(query, snapshot)
if response_message is None or not isinstance(response_message.content, str):
return
response_message.content = self.skill_mgr.remove_activation_marker(response_message.content)
@staticmethod
def _snapshot_use_funcs(use_funcs: list | None) -> list | None:
if use_funcs is None:
return None
return list(use_funcs)
def _snapshot_query_state(self, query: pipeline_query.Query) -> SkillActivationSnapshot:
return SkillActivationSnapshot(
use_funcs=self._snapshot_use_funcs(query.use_funcs),
variables=copy.deepcopy(query.variables) if query.variables is not None else None,
)
@staticmethod
def _restore_query_state(query: pipeline_query.Query, snapshot: SkillActivationSnapshot) -> None:
query.use_funcs = snapshot.use_funcs
query.variables = snapshot.variables
def prepare_skill_activation(
def register_activated_skill(
ap: app.Application,
query: pipeline_query.Query,
response_content: str | None,
) -> PreparedSkillActivation | None:
"""Prepare multi-skill activation state on the query."""
if not response_content or not getattr(ap, 'skill_mgr', None):
return None
skill_name: str,
) -> bool:
"""Register an activated skill for sandbox mount path resolution.
visible_skills = skill_loader.get_visible_skills(ap, query)
activated_skill_names = [
skill_name
for skill_name in ap.skill_mgr.detect_skill_activations(response_content)
if skill_name in visible_skills
]
if not activated_skill_names:
return None
prompt = ap.skill_mgr.build_activation_prompt_for_skills(activated_skill_names)
if not prompt:
return None
for skill_name in activated_skill_names:
skill_data = ap.skill_mgr.get_skill_by_name(skill_name)
if skill_data:
skill_loader.register_activated_skill(query, skill_data)
return PreparedSkillActivation(
activated_skill_names=activated_skill_names,
cleaned_content=ap.skill_mgr.remove_activation_marker(response_content),
prompt=prompt,
)
def get_skill_activation_coordinator(ap: app.Application) -> SkillActivationCoordinator | None:
This is called by the activate tool when a skill is activated via Tool Call.
"""
skill_mgr = getattr(ap, 'skill_mgr', None)
if skill_mgr is None:
return None
return False
required_methods = (
'detect_skill_activations',
'remove_activation_marker',
)
if any(not hasattr(skill_mgr, method_name) for method_name in required_methods):
return None
skill_data = skill_mgr.get_skill_by_name(skill_name)
if skill_data is None:
return False
return SkillActivationCoordinator(ap, skill_mgr)
skill_loader.register_activated_skill(query, skill_data)
return True

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,14 +35,32 @@ 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_name = skill_data['name']
self.skills[skill_name] = skill_data
# 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,
@@ -47,15 +71,16 @@ class SkillManager:
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'
f'User skill "{skill_name}" overrides builtin skill'
)
continue
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:
"""Get skill data by name."""
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()

View File

@@ -0,0 +1,104 @@
---
name: create-skill
description: Create new skills for LangBot. Use when users want to create, modify, or register a skill. Helps draft SKILL.md with proper frontmatter and register skills from sandbox directories.
---
# Create Skill
A skill for creating new LangBot skills.
## Skill Structure
A skill is a directory containing, at minimum, a `SKILL.md` file:
```
skill-name/
├── SKILL.md # Required: metadata + instructions
├── scripts/ # Optional: helper scripts
├── references/ # Optional: documentation
├── assets/ # Optional: templates, resources
```
## SKILL.md Format
SKILL.md must contain YAML frontmatter followed by Markdown content:
```markdown
---
name: skill-name
display_name: Skill Display Name
description: What this skill does and when to use it.
---
# Skill Title
Instructions for how to use this skill...
## Examples
- Example 1
- Example 2
```
### Frontmatter Fields
| Field | Required | Description |
|-------|----------|-------------|
| `name` | Yes | Skill identifier. 1-64 chars, lowercase letters, numbers, hyphens only. Must not start or end with hyphen. |
| `display_name` | No | Human-readable name shown in UI. |
| `description` | Yes | What the skill does and when to use it. Max 1024 chars. Include keywords for triggering. |
| `license` | No | License name or reference. |
| `metadata` | No | Additional key-value metadata. |
### Body Content
The Markdown body contains skill instructions. Recommended sections:
- Step-by-step instructions
- Examples
- Common edge cases
- Guidelines
Keep SKILL.md under 500 lines. Move detailed content to `references/` directory.
## Creating a Skill
1. **Understand requirements**: Ask what the skill should do
2. **Draft SKILL.md**: Create frontmatter + instructions
3. **Create in sandbox**: Write files to `/workspace/{skill-name}/`
4. **Register**: Use `register_skill` tool to register to LangBot store
### Name Rules
- Lowercase letters, numbers, hyphens only
- Cannot start or end with hyphen
- No consecutive hyphens (`--`)
- 1-64 characters
Valid: `pdf-processing`, `data-analysis`, `code-review`
Invalid: `PDF-Processing`, `-pdf`, `pdf--processing`
### Description Tips
Good description describes both what and when:
```yaml
description: Extracts text from PDF files. Use when working with PDF documents or when user mentions PDFs.
```
Poor description:
```yaml
description: Helps with PDFs.
```
## Workflow Example
1. User: "Create a skill for generating reports"
2. Ask clarifying questions about report format, templates, etc.
3. Create `/workspace/report-generator/SKILL.md`
4. Optionally create helper scripts in `/workspace/report-generator/scripts/`
5. Call `register_skill(path="/workspace/report-generator", name="report-generator")`
6. Skill is now available via `activate(skill_name="report-generator")`
## After Registration
The skill package is copied to `data/skills/` and loaded by LangBot.
User can activate it immediately with the `activate` tool.