From 7145447bcbced8387a6ee1eb2716592f51dcb029 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Wed, 13 May 2026 21:15:39 +0800 Subject: [PATCH] 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 --- src/langbot/pkg/pipeline/preproc/preproc.py | 46 +- .../pkg/provider/runners/localagent.py | 134 +----- .../provider/tools/loaders/skill_authoring.py | 451 +++++++----------- src/langbot/pkg/skill/activation.py | 153 +----- src/langbot/pkg/skill/manager.py | 214 +++------ .../templates/skills/create-skill/SKILL.md | 104 ++++ 6 files changed, 364 insertions(+), 738 deletions(-) create mode 100644 src/langbot/templates/skills/create-skill/SKILL.md diff --git a/src/langbot/pkg/pipeline/preproc/preproc.py b/src/langbot/pkg/pipeline/preproc/preproc.py index ab8d5bfe..9cbe9141 100644 --- a/src/langbot/pkg/pipeline/preproc/preproc.py +++ b/src/langbot/pkg/pipeline/preproc/preproc.py @@ -248,7 +248,9 @@ class PreProcessor(stage.PipelineStage): query.prompt.messages = event_ctx.event.default_prompt query.messages = event_ctx.event.prompt - # =========== Inject skill index into system prompt =========== + # =========== Store bound skills for runtime visibility checks =========== + # Skills are now activated through the `activate` tool (Tool Call mechanism), + # not through system prompt injection. This aligns with Claude Code's design. if selected_runner == 'local-agent' and self.ap.skill_mgr: # Get bound skills from pipeline extensions_preferences pipeline_data = await self.ap.pipeline_service.get_pipeline(query.pipeline_uuid) @@ -264,42 +266,12 @@ class PreProcessor(stage.PipelineStage): # Store bound skills in query variables for runtime path visibility checks query.variables['_pipeline_bound_skills'] = bound_skills - # Build skill awareness addition - skill_addition = self.ap.skill_mgr.build_skill_aware_prompt_addition( - pipeline_uuid=query.pipeline_uuid, - bound_skills=bound_skills, + loaded_count = len(self.ap.skill_mgr.skills) + self.ap.logger.debug( + f'Skills available for activate tool: ' + 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) diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index f242ecc9..3235167a 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -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 diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 209979b9..ed3bf518 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -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'The "{skill_name}" skill is activated\n' + result_content += f'\n' + result_content += f'{skill_name}\n' + result_content += f'{mount_path}\n' + result_content += f'{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'\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 section + available_skills_lines = [''] + 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'') + available_skills_lines.append(f'{skill_name}') + available_skills_lines.append(f'{description}') + available_skills_lines.append(f'') + available_skills_lines.append('') + + available_skills_block = '\n'.join(available_skills_lines) + + return f'''Activate a skill within the main conversation. + + +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 +The skill is activated + +- 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 below +- Do not invoke a skill that is already running +- To create a new skill: prepare it in /workspace, then use register_skill tool + + +{available_skills_block}''' \ No newline at end of file diff --git a/src/langbot/pkg/skill/activation.py b/src/langbot/pkg/skill/activation.py index 4f142a0c..ce9f1852 100644 --- a/src/langbot/pkg/skill/activation.py +++ b/src/langbot/pkg/skill/activation.py @@ -1,7 +1,5 @@ from __future__ import annotations -import copy -from dataclasses import dataclass import typing import langbot_plugin.api.entities.builtin.provider.message as provider_message @@ -13,147 +11,26 @@ if typing.TYPE_CHECKING: import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query -@dataclass -class PreparedSkillActivation: - activated_skill_names: list[str] - cleaned_content: str - prompt: str +# Skill activation is now handled through Tool Call mechanism (activate tool). +# This file is kept for potential future extensions but the text marker +# detection mechanism has been removed. - -@dataclass -class SkillActivationSnapshot: - use_funcs: list | None - variables: dict | None - - -@dataclass -class SkillActivationPlan: - activated_skill_names: list[str] - cleaned_content: str - system_message: provider_message.Message - snapshot: SkillActivationSnapshot - - -class SkillActivationCoordinator: - """Owns the skill activation protocol around the local-agent runner.""" - - def __init__(self, ap: app.Application, skill_mgr: typing.Any): - self.ap = ap - self.skill_mgr = skill_mgr - - def inspect_initial_content(self, content: str | None, is_final: bool) -> str: - if not content: - return 'emit' - - stripped = content.lstrip() - if not stripped: - return 'undecided' - - marker = str(getattr(self.skill_mgr, 'SKILL_ACTIVATION_MARKER', '[ACTIVATE_SKILL:')) - if stripped.startswith(marker): - return 'buffer' - if not is_final and marker.startswith(stripped): - return 'undecided' - return 'emit' - - def prepare_followup( - self, - query: pipeline_query.Query, - response_content: str | None, - ) -> SkillActivationPlan | None: - snapshot = self._snapshot_query_state(query) - try: - activation = prepare_skill_activation(self.ap, query, response_content) - except Exception: - self._restore_query_state(query, snapshot) - raise - - if not activation: - return None - - return SkillActivationPlan( - activated_skill_names=activation.activated_skill_names, - cleaned_content=activation.cleaned_content, - system_message=provider_message.Message(role='system', content=activation.prompt), - snapshot=snapshot, - ) - - def rollback( - self, - query: pipeline_query.Query, - snapshot: SkillActivationSnapshot | None, - response_message: provider_message.Message | provider_message.MessageChunk | None, - ) -> None: - if snapshot is not None: - self._restore_query_state(query, snapshot) - - if response_message is None or not isinstance(response_message.content, str): - return - - response_message.content = self.skill_mgr.remove_activation_marker(response_message.content) - - @staticmethod - def _snapshot_use_funcs(use_funcs: list | None) -> list | None: - if use_funcs is None: - return None - return list(use_funcs) - - def _snapshot_query_state(self, query: pipeline_query.Query) -> SkillActivationSnapshot: - return SkillActivationSnapshot( - use_funcs=self._snapshot_use_funcs(query.use_funcs), - variables=copy.deepcopy(query.variables) if query.variables is not None else None, - ) - - @staticmethod - def _restore_query_state(query: pipeline_query.Query, snapshot: SkillActivationSnapshot) -> None: - query.use_funcs = snapshot.use_funcs - query.variables = snapshot.variables - - -def prepare_skill_activation( +def register_activated_skill( ap: app.Application, query: pipeline_query.Query, - response_content: str | None, -) -> PreparedSkillActivation | None: - """Prepare multi-skill activation state on the query.""" - if not response_content or not getattr(ap, 'skill_mgr', None): - return None + skill_name: str, +) -> bool: + """Register an activated skill for sandbox mount path resolution. - visible_skills = skill_loader.get_visible_skills(ap, query) - activated_skill_names = [ - skill_name - for skill_name in ap.skill_mgr.detect_skill_activations(response_content) - if skill_name in visible_skills - ] - if not activated_skill_names: - return None - - prompt = ap.skill_mgr.build_activation_prompt_for_skills(activated_skill_names) - if not prompt: - return None - - for skill_name in activated_skill_names: - skill_data = ap.skill_mgr.get_skill_by_name(skill_name) - if skill_data: - skill_loader.register_activated_skill(query, skill_data) - - return PreparedSkillActivation( - activated_skill_names=activated_skill_names, - cleaned_content=ap.skill_mgr.remove_activation_marker(response_content), - prompt=prompt, - ) - - -def get_skill_activation_coordinator(ap: app.Application) -> SkillActivationCoordinator | None: + This is called by the activate tool when a skill is activated via Tool Call. + """ skill_mgr = getattr(ap, 'skill_mgr', None) if skill_mgr is None: - return None + return False - required_methods = ( - 'detect_skill_activations', - 'remove_activation_marker', - ) - if any(not hasattr(skill_mgr, method_name) for method_name in required_methods): - return None + skill_data = skill_mgr.get_skill_by_name(skill_name) + if skill_data is None: + return False - return SkillActivationCoordinator(ap, skill_mgr) + skill_loader.register_activated_skill(query, skill_data) + return True \ No newline at end of file diff --git a/src/langbot/pkg/skill/manager.py b/src/langbot/pkg/skill/manager.py index d227fceb..e29027b2 100644 --- a/src/langbot/pkg/skill/manager.py +++ b/src/langbot/pkg/skill/manager.py @@ -2,7 +2,6 @@ from __future__ import annotations import datetime as dt import os -import re import typing from ..core import app @@ -14,9 +13,16 @@ if typing.TYPE_CHECKING: class SkillManager: - """Skill manager backed purely by filesystem packages under data/skills.""" + """Skill manager backed by filesystem packages. - SKILL_ACTIVATION_MARKER = '[ACTIVATE_SKILL:' + Skills are loaded from two sources: + 1. Builtin skills: templates/skills/ (shipped with LangBot) + 2. User skills: data/skills/ (created by users) + + Skills are activated through the `activate` tool (Tool Call mechanism), + aligned with Claude Code's design. This protects KV Cache and follows + industry standard. + """ ap: app.Application skills: dict[str, dict] @@ -29,33 +35,52 @@ class SkillManager: await self.reload_skills() async def reload_skills(self): + """Reload all skills from builtin and user directories. + + NOTE: This performs a full scan of both directories. For registering a single + new skill, consider adding it directly to self.skills instead of reloading all. + Current implementation is acceptable for typical skill counts (<50). + """ self.skills = {} - skills_root = self.get_managed_skills_root() - if not os.path.isdir(skills_root): - self.ap.logger.info('Loaded 0 skills') - return + # Load builtin skills first (templates/skills/) + builtin_root = self.get_builtin_skills_root() + if os.path.isdir(builtin_root): + for package_root, entry_file in self._discover_skill_directories(builtin_root): + skill_data = { + 'package_root': package_root, + 'entry_file': entry_file, + } + if not self._load_skill_file(skill_data): + continue - for package_root, entry_file in self._discover_skill_directories(skills_root): - skill_data = { - 'package_root': package_root, - 'entry_file': entry_file, - } - if not self._load_skill_file(skill_data): - continue + skill_name = skill_data['name'] + self.skills[skill_name] = skill_data - skill_name = skill_data['name'] - if skill_name in self.skills: - self.ap.logger.warning( - f'Duplicate skill name "{skill_name}" found at {package_root}, skipping later entry' - ) - continue + # Load user skills (data/skills/) - can override builtin if same name + managed_root = self.get_managed_skills_root() + if os.path.isdir(managed_root): + for package_root, entry_file in self._discover_skill_directories(managed_root): + skill_data = { + 'package_root': package_root, + 'entry_file': entry_file, + } + if not self._load_skill_file(skill_data): + continue - self.skills[skill_name] = skill_data + skill_name = skill_data['name'] + if skill_name in self.skills: + self.ap.logger.warning( + f'User skill "{skill_name}" overrides builtin skill' + ) + self.skills[skill_name] = skill_data - self.ap.logger.info(f'Loaded {len(self.skills)} skills') + builtin_count = sum(1 for s in self.skills.values() if s.get('package_root', '').startswith(builtin_root)) + user_count = len(self.skills) - builtin_count + self.ap.logger.info(f'Loaded {len(self.skills)} skills ({builtin_count} builtin, {user_count} user)') def refresh_skill_from_disk(self, skill_name: str) -> bool: + """Refresh a single skill from disk.""" if not skill_name: return False @@ -71,9 +96,16 @@ class SkillManager: @staticmethod def get_managed_skills_root() -> str: + """Get the root directory for managed user skills.""" return paths.get_data_path('skills') + @staticmethod + def get_builtin_skills_root() -> str: + """Get the root directory for builtin skills (templates/skills/).""" + return paths.get_resource_path('templates/skills') + def _discover_skill_directories(self, root_path: str, max_depth: int = 6) -> list[tuple[str, str]]: + """Discover all skill directories under root_path.""" discovered: list[tuple[str, str]] = [] root_path = os.path.realpath(os.path.abspath(root_path)) root_depth = root_path.rstrip(os.sep).count(os.sep) @@ -95,12 +127,14 @@ class SkillManager: @staticmethod def _find_skill_entry(path: str) -> tuple[str, str] | None: + """Find SKILL.md entry file in a directory.""" for candidate in ('SKILL.md', 'skill.md'): if os.path.isfile(os.path.join(path, candidate)): return path, candidate return None def _load_skill_file(self, skill_data: dict) -> bool: + """Load skill data from SKILL.md file.""" package_root = self._normalize_package_root(skill_data.get('package_root', '')) entry_file = skill_data.get('entry_file', 'SKILL.md') if not package_root: @@ -148,137 +182,5 @@ class SkillManager: return os.path.realpath(os.path.abspath(package_root)) def get_skill_by_name(self, name: str) -> dict | None: - return self.skills.get(name) - - def get_skill_index(self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None) -> str: - skills_to_index = [] - for skill in self.skills.values(): - if bound_skills is not None and skill['name'] not in bound_skills: - continue - skills_to_index.append(skill) - - if not skills_to_index: - return '' - - lines = ['Available Skills:'] - for skill in skills_to_index: - display = skill.get('display_name') or skill['name'] - lines.append(f'- {skill["name"]} ({display}): {skill.get("description", "")}') - return '\n'.join(lines) - - def build_skill_aware_prompt_addition( - self, pipeline_uuid: str | None = None, bound_skills: list[str] | None = None - ) -> str: - skill_index = self.get_skill_index(pipeline_uuid, bound_skills) - if not skill_index: - return '' - - return f""" - -{skill_index} - -When the user's request clearly matches one or more skills based on their descriptions, you should activate them. -To activate a skill, include this marker at the beginning of your response: [ACTIVATE_SKILL: skill-name] -If multiple skills are needed, include multiple activation markers at the beginning of your response, one per line. -After activation, the selected skills' detailed instructions will be loaded for you to follow. -Use the first activated skill as the primary skill. Use any additional activated skills as supporting guidance. -If you need to inspect a visible skill before activation, use `read` on `/workspace/.skills//SKILL.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""" - - -## 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`. - - - -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""" -\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/`.\nFor a given skill, set `exec.workdir` to `/workspace/.skills/` 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 -""".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/` path whenever you need to inspect or modify an activated skill package. -Respond to the user with one coherent answer that integrates the activated skills. -""" - - @staticmethod - def remove_activation_marker(response: str) -> str: - return re.sub(r'\[ACTIVATE_SKILL:\s*\S+?\s*\]\s*', '', response).lstrip() + """Get skill data by name.""" + return self.skills.get(name) \ No newline at end of file diff --git a/src/langbot/templates/skills/create-skill/SKILL.md b/src/langbot/templates/skills/create-skill/SKILL.md new file mode 100644 index 00000000..2ae143c5 --- /dev/null +++ b/src/langbot/templates/skills/create-skill/SKILL.md @@ -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. \ No newline at end of file