mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-28 00:14:21 +00:00
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:
@@ -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}'''
|
||||
Reference in New Issue
Block a user