mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +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:
@@ -248,7 +248,9 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt.messages = event_ctx.event.default_prompt
|
query.prompt.messages = event_ctx.event.default_prompt
|
||||||
query.messages = event_ctx.event.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:
|
if selected_runner == 'local-agent' and self.ap.skill_mgr:
|
||||||
# Get bound skills from pipeline extensions_preferences
|
# Get bound skills from pipeline extensions_preferences
|
||||||
pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid)
|
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
|
# Store bound skills in query variables for runtime path visibility checks
|
||||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||||
|
|
||||||
# Build skill awareness addition
|
loaded_count = len(self.ap.skill_mgr.skills)
|
||||||
skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition(
|
self.ap.logger.debug(
|
||||||
pipeline_uuid=query.pipeline_uuid,
|
f'Skills available for activate tool: '
|
||||||
bound_skills=bound_skills,
|
f'pipeline={query.pipeline_uuid} '
|
||||||
|
f'loaded_skills={loaded_count} '
|
||||||
|
f'bound_skills={bound_skills or "all"}'
|
||||||
)
|
)
|
||||||
|
|
||||||
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'pipeline={query.pipeline_uuid} '
|
|
||||||
f'loaded_skills={loaded_count} '
|
|
||||||
f'bound_skills={bound_skills}'
|
|
||||||
)
|
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -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.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||||
from ...skill.activation import get_skill_activation_coordinator
|
|
||||||
|
|
||||||
|
|
||||||
rag_combined_prompt_template = """
|
rag_combined_prompt_template = """
|
||||||
@@ -160,7 +159,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
"""Run request"""
|
"""Run request"""
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
initial_response_emitted = False
|
initial_response_emitted = False
|
||||||
skill_activation = get_skill_activation_coordinator(self.ap)
|
|
||||||
|
|
||||||
# Get knowledge bases list from query variables (set by PreProcessor,
|
# Get knowledge bases list from query variables (set by PreProcessor,
|
||||||
# may have been modified by plugins during PromptPreProcessing)
|
# may have been modified by plugins during PromptPreProcessing)
|
||||||
@@ -302,7 +300,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
accumulated_content = ''
|
accumulated_content = ''
|
||||||
last_role = 'assistant'
|
last_role = 'assistant'
|
||||||
msg_sequence = 1
|
msg_sequence = 1
|
||||||
suppress_initial_stream = False
|
|
||||||
|
|
||||||
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||||
query,
|
query,
|
||||||
@@ -333,31 +330,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
|
|
||||||
emitted_this_round = False
|
if msg_idx % 8 == 0 or msg.is_final:
|
||||||
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):
|
|
||||||
msg_sequence += 1
|
msg_sequence += 1
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
@@ -380,111 +353,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
if isinstance(final_msg, provider_message.MessageChunk):
|
if isinstance(final_msg, provider_message.MessageChunk):
|
||||||
first_end_sequence = final_msg.msg_sequence
|
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:
|
if not is_stream:
|
||||||
yield final_msg
|
yield final_msg
|
||||||
initial_response_emitted = True
|
initial_response_emitted = True
|
||||||
|
|||||||
@@ -7,34 +7,22 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|||||||
|
|
||||||
from .. import loader
|
from .. import loader
|
||||||
|
|
||||||
# Skill authoring needs a managed abstraction above the generic box tools.
|
# Align with Claude Code's Skill tool design:
|
||||||
# Pure prompt skills are just metadata plus SKILL.md instructions, so creating
|
# - activate: Activate a skill via Tool Call, returns SKILL.md content
|
||||||
# or updating them should not require /workspace mounts, shell access, or box
|
# - register_skill: Register a skill from sandbox directory to data/skills/
|
||||||
# to be enabled at all. These higher-level tools let local agents manage skills
|
# - This protects KV Cache and follows industry standard
|
||||||
# directly through SkillService, while import_skill_from_directory remains the
|
|
||||||
# path for file-based skills that actually need scripts or assets from box.
|
|
||||||
|
|
||||||
CREATE_SKILL_TOOL_NAME = 'create_skill'
|
ACTIVATE_SKILL_TOOL_NAME = 'activate'
|
||||||
LIST_SKILLS_TOOL_NAME = 'list_skills'
|
REGISTER_SKILL_TOOL_NAME = 'register_skill'
|
||||||
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'
|
|
||||||
|
|
||||||
AUTHORING_TOOL_NAMES = {
|
SKILL_TOOL_NAMES = {
|
||||||
CREATE_SKILL_TOOL_NAME,
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
LIST_SKILLS_TOOL_NAME,
|
REGISTER_SKILL_TOOL_NAME,
|
||||||
GET_SKILL_TOOL_NAME,
|
|
||||||
UPDATE_SKILL_TOOL_NAME,
|
|
||||||
DELETE_SKILL_TOOL_NAME,
|
|
||||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
|
||||||
RELOAD_SKILLS_TOOL_NAME,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SkillAuthoringToolLoader(loader.ToolLoader):
|
class SkillToolLoader(loader.ToolLoader):
|
||||||
"""Minimal system actions for filesystem-backed skills."""
|
"""Skill tools aligned with Claude Code's design."""
|
||||||
|
|
||||||
def __init__(self, ap):
|
def __init__(self, ap):
|
||||||
super().__init__(ap)
|
super().__init__(ap)
|
||||||
@@ -42,304 +30,187 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
self._tools = [
|
self._tools = [
|
||||||
self._build_create_skill_tool(),
|
self._build_activate_skill_tool(),
|
||||||
self._build_list_skills_tool(),
|
self._build_register_skill_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(),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
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 []
|
||||||
return list(self._tools)
|
return list(self._tools)
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
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:
|
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||||
if name == CREATE_SKILL_TOOL_NAME:
|
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||||
return await self._invoke_create_skill(parameters)
|
return await self._invoke_activate_skill(parameters, query)
|
||||||
if name == LIST_SKILLS_TOOL_NAME:
|
if name == REGISTER_SKILL_TOOL_NAME:
|
||||||
return await self._invoke_list_skills()
|
return await self._invoke_register_skill(parameters)
|
||||||
if name == GET_SKILL_TOOL_NAME:
|
raise ValueError(f'Unknown 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}')
|
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _has_authoring_services(self) -> bool:
|
def _has_skill_manager(self) -> bool:
|
||||||
return getattr(self.ap, 'skill_service', None) is not None
|
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 {
|
return {
|
||||||
'reloaded': True,
|
'activated': True,
|
||||||
'skill_names': [skill['name'] for skill in skills],
|
'skill_name': skill_name,
|
||||||
'count': len(skills),
|
'mount_path': mount_path,
|
||||||
|
'content': result_content,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _invoke_create_skill(self, parameters: dict) -> typing.Any:
|
async def _invoke_register_skill(self, parameters: dict) -> typing.Any:
|
||||||
name = str(parameters.get('name', '') or '').strip()
|
"""Register a skill from sandbox directory to data/skills/."""
|
||||||
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:
|
|
||||||
sandbox_path = str(parameters.get('path', '') or '').strip()
|
sandbox_path = str(parameters.get('path', '') or '').strip()
|
||||||
if not sandbox_path:
|
if not sandbox_path:
|
||||||
raise ValueError('path is required')
|
raise ValueError('path is required')
|
||||||
|
|
||||||
|
# Resolve sandbox path to host path
|
||||||
host_path = self._resolve_workspace_directory(sandbox_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(),
|
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
||||||
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
||||||
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
||||||
'package_root': host_path,
|
'package_root': host_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'imported': True,
|
'registered': True,
|
||||||
|
'skill_name': skill_name,
|
||||||
'source_path': sandbox_path,
|
'source_path': sandbox_path,
|
||||||
'skill': created,
|
'skill': created,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
|
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
|
||||||
|
"""Resolve sandbox path to host filesystem path."""
|
||||||
box_service = getattr(self.ap, 'box_service', None)
|
box_service = getattr(self.ap, 'box_service', None)
|
||||||
workspace_root = getattr(box_service, 'default_workspace', None)
|
workspace_root = getattr(box_service, 'default_workspace', None)
|
||||||
if not workspace_root:
|
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'
|
normalized_path = str(sandbox_path).strip() or '/workspace'
|
||||||
if not normalized_path.startswith('/workspace'):
|
if not normalized_path.startswith('/workspace'):
|
||||||
raise ValueError('path must be under /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_root = os.path.realpath(workspace_root)
|
||||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
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)):
|
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||||
raise ValueError('path escapes the workspace boundary')
|
raise ValueError('path escapes the workspace boundary')
|
||||||
|
|
||||||
if not os.path.isdir(host_path):
|
if not os.path.isdir(host_path):
|
||||||
raise ValueError(f'Directory does not exist: {sandbox_path}')
|
raise ValueError(f'Directory does not exist: {sandbox_path}')
|
||||||
|
|
||||||
return host_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(
|
return resource_tool.LLMTool(
|
||||||
name=CREATE_SKILL_TOOL_NAME,
|
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||||
human_desc='Create a managed skill',
|
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=(
|
description=(
|
||||||
'Create a new managed skill directly in the skills store without using /workspace. '
|
'Register a skill package from a directory under /workspace into LangBot\'s skill store. '
|
||||||
'Use this for prompt-only skills or simple skills whose main content is the SKILL.md instructions. '
|
'Use this after creating or preparing a skill in the sandbox with exec/read/write/edit. '
|
||||||
'Pure prompt skills should not depend on box or a workspace directory just to be created or edited later.'
|
'The directory must contain a SKILL.md file. '
|
||||||
),
|
'After registration, the skill can be activated with the activate tool.'
|
||||||
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.'
|
|
||||||
),
|
),
|
||||||
parameters={
|
parameters={
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
'properties': {
|
'properties': {
|
||||||
'path': {
|
'path': {
|
||||||
'type': 'string',
|
'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': {
|
'name': {
|
||||||
'type': 'string',
|
'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': {
|
'display_name': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
@@ -360,18 +231,50 @@ class SkillAuthoringToolLoader(loader.ToolLoader):
|
|||||||
func=lambda parameters: parameters,
|
func=lambda parameters: parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_reload_skills_tool(self) -> resource_tool.LLMTool:
|
def _build_activate_tool_description(self) -> str:
|
||||||
return resource_tool.LLMTool(
|
"""Build tool description with embedded available_skills list."""
|
||||||
name=RELOAD_SKILLS_TOOL_NAME,
|
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||||
human_desc='Reload filesystem skills',
|
if skill_mgr is None:
|
||||||
description=(
|
return 'Activate a skill. No skills are currently available.'
|
||||||
'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.'
|
skills = getattr(skill_mgr, 'skills', {})
|
||||||
),
|
if not skills:
|
||||||
parameters={
|
return 'Activate a skill. No skills are currently available.'
|
||||||
'type': 'object',
|
|
||||||
'properties': {},
|
# Build <available_skills> section
|
||||||
'additionalProperties': False,
|
available_skills_lines = ['<available_skills>']
|
||||||
},
|
for skill_name, skill_data in sorted(skills.items()):
|
||||||
func=lambda parameters: parameters,
|
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}'''
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
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
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Skill activation is now handled through Tool Call mechanism (activate tool).
|
||||||
class PreparedSkillActivation:
|
# This file is kept for potential future extensions but the text marker
|
||||||
activated_skill_names: list[str]
|
# detection mechanism has been removed.
|
||||||
cleaned_content: str
|
|
||||||
prompt: str
|
|
||||||
|
|
||||||
|
def register_activated_skill(
|
||||||
@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(
|
|
||||||
ap: app.Application,
|
ap: app.Application,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
response_content: str | None,
|
skill_name: str,
|
||||||
) -> PreparedSkillActivation | None:
|
) -> bool:
|
||||||
"""Prepare multi-skill activation state on the query."""
|
"""Register an activated skill for sandbox mount path resolution.
|
||||||
if not response_content or not getattr(ap, 'skill_mgr', None):
|
|
||||||
return None
|
|
||||||
|
|
||||||
visible_skills = skill_loader.get_visible_skills(ap, query)
|
This is called by the activate tool when a skill is activated via Tool Call.
|
||||||
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:
|
|
||||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||||
if skill_mgr is None:
|
if skill_mgr is None:
|
||||||
return None
|
return False
|
||||||
|
|
||||||
required_methods = (
|
skill_data = skill_mgr.get_skill_by_name(skill_name)
|
||||||
'detect_skill_activations',
|
if skill_data is None:
|
||||||
'remove_activation_marker',
|
return False
|
||||||
)
|
|
||||||
if any(not hasattr(skill_mgr, method_name) for method_name in required_methods):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return SkillActivationCoordinator(ap, skill_mgr)
|
skill_loader.register_activated_skill(query, skill_data)
|
||||||
|
return True
|
||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from ..core import app
|
from ..core import app
|
||||||
@@ -14,9 +13,16 @@ if typing.TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class SkillManager:
|
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
|
ap: app.Application
|
||||||
skills: dict[str, dict]
|
skills: dict[str, dict]
|
||||||
@@ -29,33 +35,52 @@ class SkillManager:
|
|||||||
await self.reload_skills()
|
await self.reload_skills()
|
||||||
|
|
||||||
async def reload_skills(self):
|
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 = {}
|
self.skills = {}
|
||||||
|
|
||||||
skills_root = self.get_managed_skills_root()
|
# Load builtin skills first (templates/skills/)
|
||||||
if not os.path.isdir(skills_root):
|
builtin_root = self.get_builtin_skills_root()
|
||||||
self.ap.logger.info('Loaded 0 skills')
|
if os.path.isdir(builtin_root):
|
||||||
return
|
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']
|
||||||
skill_data = {
|
self.skills[skill_name] = skill_data
|
||||||
'package_root': package_root,
|
|
||||||
'entry_file': entry_file,
|
|
||||||
}
|
|
||||||
if not self._load_skill_file(skill_data):
|
|
||||||
continue
|
|
||||||
|
|
||||||
skill_name = skill_data['name']
|
# Load user skills (data/skills/) - can override builtin if same name
|
||||||
if skill_name in self.skills:
|
managed_root = self.get_managed_skills_root()
|
||||||
self.ap.logger.warning(
|
if os.path.isdir(managed_root):
|
||||||
f'Duplicate skill name "{skill_name}" found at {package_root}, skipping later entry'
|
for package_root, entry_file in self._discover_skill_directories(managed_root):
|
||||||
)
|
skill_data = {
|
||||||
continue
|
'package_root': package_root,
|
||||||
|
'entry_file': entry_file,
|
||||||
|
}
|
||||||
|
if not self._load_skill_file(skill_data):
|
||||||
|
continue
|
||||||
|
|
||||||
self.skills[skill_name] = skill_data
|
skill_name = skill_data['name']
|
||||||
|
if skill_name in self.skills:
|
||||||
|
self.ap.logger.warning(
|
||||||
|
f'User skill "{skill_name}" overrides builtin skill'
|
||||||
|
)
|
||||||
|
self.skills[skill_name] = skill_data
|
||||||
|
|
||||||
self.ap.logger.info(f'Loaded {len(self.skills)} skills')
|
builtin_count = sum(1 for s in self.skills.values() if s.get('package_root', '').startswith(builtin_root))
|
||||||
|
user_count = len(self.skills) - builtin_count
|
||||||
|
self.ap.logger.info(f'Loaded {len(self.skills)} skills ({builtin_count} builtin, {user_count} user)')
|
||||||
|
|
||||||
def refresh_skill_from_disk(self, skill_name: str) -> bool:
|
def refresh_skill_from_disk(self, skill_name: str) -> bool:
|
||||||
|
"""Refresh a single skill from disk."""
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -71,9 +96,16 @@ class SkillManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_managed_skills_root() -> str:
|
def get_managed_skills_root() -> str:
|
||||||
|
"""Get the root directory for managed user skills."""
|
||||||
return paths.get_data_path('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]]:
|
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]] = []
|
discovered: list[tuple[str, str]] = []
|
||||||
root_path = os.path.realpath(os.path.abspath(root_path))
|
root_path = os.path.realpath(os.path.abspath(root_path))
|
||||||
root_depth = root_path.rstrip(os.sep).count(os.sep)
|
root_depth = root_path.rstrip(os.sep).count(os.sep)
|
||||||
@@ -95,12 +127,14 @@ class SkillManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_skill_entry(path: str) -> tuple[str, str] | None:
|
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'):
|
for candidate in ('SKILL.md', 'skill.md'):
|
||||||
if os.path.isfile(os.path.join(path, candidate)):
|
if os.path.isfile(os.path.join(path, candidate)):
|
||||||
return path, candidate
|
return path, candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _load_skill_file(self, skill_data: dict) -> bool:
|
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', ''))
|
package_root = self._normalize_package_root(skill_data.get('package_root', ''))
|
||||||
entry_file = skill_data.get('entry_file', 'SKILL.md')
|
entry_file = skill_data.get('entry_file', 'SKILL.md')
|
||||||
if not package_root:
|
if not package_root:
|
||||||
@@ -148,137 +182,5 @@ class SkillManager:
|
|||||||
return os.path.realpath(os.path.abspath(package_root))
|
return os.path.realpath(os.path.abspath(package_root))
|
||||||
|
|
||||||
def get_skill_by_name(self, name: str) -> dict | None:
|
def get_skill_by_name(self, name: str) -> dict | None:
|
||||||
return self.skills.get(name)
|
"""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()
|
|
||||||
104
src/langbot/templates/skills/create-skill/SKILL.md
Normal file
104
src/langbot/templates/skills/create-skill/SKILL.md
Normal 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.
|
||||||
Reference in New Issue
Block a user