mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 11:25:35 +08:00
feat(skill): wire standard host skill contract (#2771)
* feat(skill): wire standard host skill contract * chore(skill): pin bounded SDK revision * fix(skill): correct SDK pin and formatting * chore: use released SDK 0.7.17 * fix(deps): validate nested component imports with SDK 0.7.18rc1 * fix(deps): pin validated SDK 0.7.18
This commit is contained in:
+1
-1
@@ -71,7 +71,7 @@ dependencies = [
|
||||
"langchain-text-splitters>=1.1.2",
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.7.16",
|
||||
"langbot-plugin==0.7.18",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -23,6 +23,7 @@ _PUBLIC_SKILL_FIELDS = (
|
||||
'name',
|
||||
'display_name',
|
||||
'description',
|
||||
'metadata',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'revision',
|
||||
|
||||
@@ -13,16 +13,19 @@ from ....api.http.context import ExecutionContext
|
||||
# - register_skill: Register a skill from sandbox directory to data/skills/
|
||||
# - This protects KV Cache and follows industry standard
|
||||
|
||||
LIST_SKILLS_TOOL_NAME = 'list_skills'
|
||||
ACTIVATE_SKILL_TOOL_NAME = 'activate'
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME = 'list_skill_resources'
|
||||
READ_SKILL_RESOURCE_TOOL_NAME = 'read_skill_resource'
|
||||
REGISTER_SKILL_TOOL_NAME = 'register_skill'
|
||||
|
||||
READ_ONLY_SKILL_TOOL_NAMES = {
|
||||
LIST_SKILLS_TOOL_NAME,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
LIST_SKILL_RESOURCES_TOOL_NAME,
|
||||
READ_SKILL_RESOURCE_TOOL_NAME,
|
||||
}
|
||||
_SKILL_CATALOG_FIELDS = ('name', 'display_name', 'description', 'metadata')
|
||||
SANDBOX_SKILL_TOOL_NAMES = {
|
||||
REGISTER_SKILL_TOOL_NAME,
|
||||
}
|
||||
@@ -44,6 +47,7 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
async def initialize(self):
|
||||
if self._is_available():
|
||||
self._read_only_tools = [
|
||||
self._build_list_skills_tool(),
|
||||
self._build_activate_skill_tool(),
|
||||
self._build_list_skill_resources_tool(),
|
||||
self._build_read_skill_resource_tool(),
|
||||
@@ -94,6 +98,8 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return self._has_skill_manager() and getattr(self.ap, 'skill_repository', None) is not None
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
if name == LIST_SKILLS_TOOL_NAME:
|
||||
return await self._invoke_list_skills(parameters, query)
|
||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_activate_skill(parameters, query)
|
||||
if name == LIST_SKILL_RESOURCES_TOOL_NAME:
|
||||
@@ -154,6 +160,23 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _invoke_list_skills(self, parameters: dict, query) -> dict:
|
||||
"""List the authorized Host catalog without copying packages into the harness."""
|
||||
from . import skill as skill_loader
|
||||
|
||||
bound_names = skill_loader.get_bound_skill_names(query)
|
||||
catalog = await self.ap.skill_repository.list_skills(self._execution_context(query))
|
||||
if bound_names is not None:
|
||||
allowed = set(bound_names)
|
||||
catalog = [item for item in catalog if isinstance(item, dict) and item.get('name') in allowed]
|
||||
return {
|
||||
'skills': [
|
||||
{field: item[field] for field in _SKILL_CATALOG_FIELDS if field in item}
|
||||
for item in catalog
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -188,6 +211,16 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
instructions = skill_data.get('instructions', '')
|
||||
revision = str(skill_data.get('revision', '') or '')
|
||||
resource_listing = await self.ap.skill_repository.list_skill_resources(
|
||||
self._execution_context(query),
|
||||
skill_name,
|
||||
'.',
|
||||
expected_revision=revision,
|
||||
)
|
||||
resources = {
|
||||
'entries': resource_listing.get('entries', []),
|
||||
'truncated': bool(resource_listing.get('truncated', False)),
|
||||
}
|
||||
execution_available = await self._execution_available(query)
|
||||
mount_path = skill_loader.get_virtual_skill_mount_path(skill_name, revision) if execution_available else None
|
||||
|
||||
@@ -213,6 +246,7 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
'skill_name': skill_name,
|
||||
'mount_path': mount_path,
|
||||
'revision': revision,
|
||||
'resources': resources,
|
||||
'capabilities': {
|
||||
'instructions_readable': True,
|
||||
'resources_readable': True,
|
||||
@@ -356,6 +390,15 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
|
||||
return host_path
|
||||
|
||||
def _build_list_skills_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=LIST_SKILLS_TOOL_NAME,
|
||||
human_desc='List available skills',
|
||||
description='List the authorized Host skill catalog without loading package files into the agent harness.',
|
||||
parameters={'type': 'object', 'properties': {}, 'additionalProperties': False},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_activate_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=ACTIVATE_SKILL_TOOL_NAME,
|
||||
|
||||
@@ -236,7 +236,10 @@ class TestSkillToolLoader:
|
||||
skill = _make_skill_data(name='demo', instructions='Read this first')
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'demo': skill})
|
||||
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
|
||||
ap.skill_repository = SimpleNamespace(
|
||||
get_skill=AsyncMock(return_value=skill),
|
||||
list_skill_resources=AsyncMock(return_value={'entries': [], 'truncated': False}),
|
||||
)
|
||||
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
manager = ToolManager(ap)
|
||||
manager.skill_tool_loader = SkillToolLoader(ap)
|
||||
@@ -267,7 +270,15 @@ class TestSkillToolLoader:
|
||||
skill['revision'] = 'sha256:demo'
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'demo': skill})
|
||||
ap.skill_repository = SimpleNamespace(get_skill=AsyncMock(return_value=skill))
|
||||
ap.skill_repository = SimpleNamespace(
|
||||
get_skill=AsyncMock(return_value=skill),
|
||||
list_skill_resources=AsyncMock(
|
||||
return_value={
|
||||
'entries': [{'path': 'references/guide.md', 'is_dir': False}],
|
||||
'truncated': False,
|
||||
}
|
||||
),
|
||||
)
|
||||
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
@@ -279,6 +290,7 @@ class TestSkillToolLoader:
|
||||
assert result['skill_name'] == 'demo'
|
||||
assert result['mount_path'] is None
|
||||
assert result['revision'] == 'sha256:demo'
|
||||
assert result['resources']['entries'][0]['path'] == 'references/guide.md'
|
||||
assert result['capabilities']['resources_readable'] is True
|
||||
assert result['capabilities']['execution_available'] is False
|
||||
assert result['activated_skill_names'] == ['demo']
|
||||
@@ -304,6 +316,7 @@ class TestSkillToolLoader:
|
||||
ap.skill_mgr = _make_skill_manager({'demo': v1})
|
||||
ap.skill_repository = SimpleNamespace(
|
||||
get_skill=AsyncMock(side_effect=[v1, v2]),
|
||||
list_skill_resources=AsyncMock(return_value={'entries': [], 'truncated': False}),
|
||||
)
|
||||
ap.box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
|
||||
loader = SkillToolLoader(ap)
|
||||
@@ -336,6 +349,39 @@ class TestSkillToolLoader:
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_catalog_metadata_without_package_contents(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
visible = _make_skill_data(
|
||||
name='visible',
|
||||
metadata={'license': 'Apache-2.0'},
|
||||
package_root='/host/skills/visible',
|
||||
instructions='private instructions',
|
||||
)
|
||||
hidden = _make_skill_data(name='hidden', package_root='/host/skills/hidden')
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = _make_skill_manager({'visible': visible, 'hidden': hidden})
|
||||
ap.skill_repository = SimpleNamespace(list_skills=AsyncMock(return_value=[visible, hidden]))
|
||||
loader = SkillToolLoader(ap)
|
||||
query = _make_query(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
result = await loader.invoke_tool('list_skills', {}, query)
|
||||
|
||||
assert result['skills'] == [
|
||||
{
|
||||
'name': 'visible',
|
||||
'display_name': 'visible',
|
||||
'description': 'Description of visible',
|
||||
'metadata': {'license': 'Apache-2.0'},
|
||||
}
|
||||
]
|
||||
assert 'package_root' not in result['skills'][0]
|
||||
assert 'instructions' not in result['skills'][0]
|
||||
assert 'revision' not in result['skills'][0]
|
||||
ap.skill_repository.list_skills.assert_awaited_once_with(_CONTEXT)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_skill_scans_directory_and_creates_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
@@ -499,6 +545,7 @@ class TestSkillToolLoader:
|
||||
assert sorted(tool.name for tool in await loader.get_tools(sandbox_available=False)) == [
|
||||
'activate',
|
||||
'list_skill_resources',
|
||||
'list_skills',
|
||||
'read_skill_resource',
|
||||
]
|
||||
assert await loader.has_tool('activate') is True
|
||||
@@ -520,6 +567,7 @@ class TestSkillToolLoader:
|
||||
assert sorted(tool.name for tool in tools) == [
|
||||
'activate',
|
||||
'list_skill_resources',
|
||||
'list_skills',
|
||||
'read_skill_resource',
|
||||
'register_skill',
|
||||
]
|
||||
@@ -885,6 +933,7 @@ async def test_register_skill_hidden_when_cloud_scanning_is_disabled():
|
||||
|
||||
assert {tool.name for tool in await loader.get_tools(sandbox_available=True)} == {
|
||||
'activate',
|
||||
'list_skills',
|
||||
'list_skill_resources',
|
||||
'read_skill_resource',
|
||||
}
|
||||
|
||||
@@ -29,7 +29,15 @@ class TestSkillRepositoryBoundary:
|
||||
@staticmethod
|
||||
def _ap_with_repository():
|
||||
repository = SimpleNamespace(
|
||||
list_skills=AsyncMock(return_value=[{'name': 'x', 'instructions': 'Do work'}]),
|
||||
list_skills=AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'name': 'x',
|
||||
'instructions': 'Do work',
|
||||
'metadata': {'license': 'Apache-2.0'},
|
||||
}
|
||||
]
|
||||
),
|
||||
get_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:x'}),
|
||||
create_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Do work', 'revision': 'sha256:v1'}),
|
||||
update_skill=AsyncMock(return_value={'name': 'x', 'instructions': 'Updated', 'revision': 'sha256:v2'}),
|
||||
@@ -49,7 +57,13 @@ class TestSkillRepositoryBoundary:
|
||||
ap = self._ap_with_repository()
|
||||
service = SkillService(ap)
|
||||
|
||||
assert await service.list_skills(_CONTEXT) == [{'name': 'x', 'instructions': 'Do work'}]
|
||||
assert await service.list_skills(_CONTEXT) == [
|
||||
{
|
||||
'name': 'x',
|
||||
'metadata': {'license': 'Apache-2.0'},
|
||||
'instructions': 'Do work',
|
||||
}
|
||||
]
|
||||
assert await service.read_skill_file(_CONTEXT, 'x', 'a.txt') == {
|
||||
'path': 'a.txt',
|
||||
'content': 'hello',
|
||||
|
||||
@@ -2241,7 +2241,7 @@ requires-dist = [
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
|
||||
{ name = "langbot-plugin", specifier = "==0.7.16" },
|
||||
{ name = "langbot-plugin", specifier = "==0.7.18" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2311,7 +2311,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.7.16"
|
||||
version = "0.7.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2332,9 +2332,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/8c/9582a14fd989c35787254a4e4e4304ce77572439056c594cee5ee98149a4/langbot_plugin-0.7.16.tar.gz", hash = "sha256:f5a99c578f39a3f51d1f87f56a4ecd235b303ee91bdac8853dabc3fd2181bbcf", size = 721978, upload-time = "2026-10-20T15:38:31.489Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/da/aee1601e50d2c584357356b1abe502f529b66a0b155cab63b3e4cbde0839/langbot_plugin-0.7.18.tar.gz", hash = "sha256:71b8fda2c6836e107341cdf9dbb519ef9ba7b4625d88480058b5d794f6e3e256", size = 725344, upload-time = "2026-10-26T03:52:09.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/2b/aed8a96ff33ab8c9a732d9e1ffe62749c3ab0157c9286bd16b3e4b5efe99/langbot_plugin-0.7.16-py3-none-any.whl", hash = "sha256:ecf7ce622862dabb5cb8089b96ccab4a36836746b0a02fa512344eea8ec728a1", size = 459187, upload-time = "2026-10-20T15:38:30.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/5e/98b4daa0a84ba5cb80374b4b323946f62263ceec3e580b459f261abe29a7/langbot_plugin-0.7.18-py3-none-any.whl", hash = "sha256:897c1f5b291bd36acd9716e951a8cd70601e266d17f88c8b66a21426c9602f01", size = 460608, upload-time = "2026-10-26T03:52:07.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user