mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-05 05:16:03 +00:00
fix(skill): copy builtin skills to data/skills on startup
- Builtin skills (templates/skills/) are now copied to data/skills/ - Users can view and manage builtin skills in the UI - Rename SkillAuthoringToolLoader to SkillToolLoader Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,124 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""LLM工具管理器"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
native_tool_loader: native_loader.NativeToolLoader
|
||||
plugin_tool_loader: plugin_loader.PluginToolLoader
|
||||
mcp_tool_loader: mcp_loader.MCPLoader
|
||||
skill_authoring_tool_loader: skill_authoring_loader.SkillAuthoringToolLoader
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
|
||||
async def initialize(self):
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
importutil.import_modules_in_pkg(loaders)
|
||||
|
||||
self.native_tool_loader = native_loader.NativeToolLoader(self.ap)
|
||||
await self.native_tool_loader.initialize()
|
||||
|
||||
# Log native (sandbox) tool availability once at startup
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service and getattr(box_service, 'available', False):
|
||||
self.ap.logger.info('Native sandbox tools (exec/read/write/edit/glob/grep) are available.')
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||
'Box runtime is not connected — the LLM will not have access to code execution tools.'
|
||||
)
|
||||
|
||||
self.plugin_tool_loader = plugin_loader.PluginToolLoader(self.ap)
|
||||
await self.plugin_tool_loader.initialize()
|
||||
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
||||
await self.mcp_tool_loader.initialize()
|
||||
self.skill_authoring_tool_loader = skill_authoring_loader.SkillAuthoringToolLoader(self.ap)
|
||||
await self.skill_authoring_tool_loader.initialize()
|
||||
|
||||
async def get_all_tools(
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
include_skill_authoring: bool = False,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
all_functions.extend(await self.skill_authoring_tool_loader.get_tools())
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||
|
||||
return all_functions
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
function_schema = {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': function.name,
|
||||
'description': function.description,
|
||||
'parameters': function.parameters,
|
||||
},
|
||||
}
|
||||
tools.append(function_schema)
|
||||
|
||||
return tools
|
||||
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
function_schema = {
|
||||
'name': function.name,
|
||||
'description': function.description,
|
||||
'input_schema': function.parameters,
|
||||
}
|
||||
tools.append(function_schema)
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
return await self.native_tool_loader.invoke_tool(name, parameters, query)
|
||||
if await self.plugin_tool_loader.has_tool(name):
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||
if await self.mcp_tool_loader.has_tool(name):
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||
if await self.skill_authoring_tool_loader.has_tool(name):
|
||||
return await self.skill_authoring_tool_loader.invoke_tool(name, parameters, query)
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
await self.native_tool_loader.shutdown()
|
||||
await self.plugin_tool_loader.shutdown()
|
||||
await self.mcp_tool_loader.shutdown()
|
||||
await self.skill_authoring_tool_loader.shutdown()
|
||||
@@ -1,186 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import typing
|
||||
|
||||
from ..core import app
|
||||
from .utils import parse_frontmatter
|
||||
from ..utils import paths
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class SkillManager:
|
||||
"""Skill manager backed by filesystem packages.
|
||||
|
||||
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]
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
self.skills = {}
|
||||
|
||||
async def initialize(self):
|
||||
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 = {}
|
||||
|
||||
# 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
|
||||
|
||||
skill_name = skill_data['name']
|
||||
self.skills[skill_name] = skill_data
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
skill_data = self.skills.get(skill_name)
|
||||
if not skill_data:
|
||||
return False
|
||||
|
||||
if not self._load_skill_file(skill_data):
|
||||
return False
|
||||
|
||||
self.skills[skill_name] = skill_data
|
||||
return True
|
||||
|
||||
@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)
|
||||
|
||||
for current_root, dirs, _files in os.walk(root_path):
|
||||
current_root = os.path.realpath(current_root)
|
||||
depth = current_root.rstrip(os.sep).count(os.sep) - root_depth
|
||||
if depth > max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
|
||||
found = self._find_skill_entry(current_root)
|
||||
if found is not None:
|
||||
discovered.append(found)
|
||||
dirs[:] = []
|
||||
|
||||
discovered.sort(key=lambda item: item[0])
|
||||
return discovered
|
||||
|
||||
@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:
|
||||
self.ap.logger.warning('Skill package_root is empty, skipping')
|
||||
return False
|
||||
|
||||
entry_path = os.path.join(package_root, entry_file)
|
||||
try:
|
||||
with open(entry_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
self.ap.logger.warning(f'Skill entry file not found: {entry_path}, skipping')
|
||||
return False
|
||||
except OSError as exc:
|
||||
self.ap.logger.warning(f'Failed to read skill entry file {entry_path}: {exc}, skipping')
|
||||
return False
|
||||
|
||||
metadata, instructions = parse_frontmatter(content)
|
||||
name = str(metadata.get('name') or os.path.basename(os.path.normpath(package_root))).strip()
|
||||
if not name:
|
||||
self.ap.logger.warning(f'Skill at {package_root} has no valid name, skipping')
|
||||
return False
|
||||
|
||||
stat = os.stat(entry_path)
|
||||
skill_data.clear()
|
||||
skill_data.update(
|
||||
{
|
||||
'name': name,
|
||||
'display_name': str(metadata.get('display_name') or name).strip(),
|
||||
'description': str(metadata.get('description') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'raw_content': content,
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
'created_at': dt.datetime.fromtimestamp(stat.st_ctime, tz=dt.timezone.utc).isoformat(),
|
||||
'updated_at': dt.datetime.fromtimestamp(stat.st_mtime, tz=dt.timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _normalize_package_root(package_root: str) -> str:
|
||||
if not package_root:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(package_root))
|
||||
|
||||
def get_skill_by_name(self, name: str) -> dict | None:
|
||||
"""Get skill data by name."""
|
||||
return self.skills.get(name)
|
||||
Reference in New Issue
Block a user