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
+1 -133
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
@@ -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,304 +30,187 @@ 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_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')
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'
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),
'activated': True,
'skill_name': skill_name,
'mount_path': mount_path,
'content': result_content,
}
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,
}
)
return {
'created': True,
'skill': created,
}
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'):
raise ValueError('path must be under /workspace')
relative = normalized_path[len('/workspace') :].lstrip('/')
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}'''