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:
huanghuoguoguo
2026-10-26 12:11:27 +08:00
committed by GitHub
parent 33653244fc
commit b1b7ddc259
6 changed files with 116 additions and 9 deletions
+1 -1
View File
@@ -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,
+51 -2
View File
@@ -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',
}
+16 -2
View File
@@ -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',
Generated
+4 -4
View File
@@ -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]]