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