diff --git a/pyproject.toml b/pyproject.toml index 608fcab1b..dc0518418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/langbot/pkg/api/http/service/skill.py b/src/langbot/pkg/api/http/service/skill.py index bc613e7dc..9af355bd2 100644 --- a/src/langbot/pkg/api/http/service/skill.py +++ b/src/langbot/pkg/api/http/service/skill.py @@ -23,6 +23,7 @@ _PUBLIC_SKILL_FIELDS = ( 'name', 'display_name', 'description', + 'metadata', 'instructions', 'package_root', 'revision', diff --git a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py index 3e8e0060f..855980a6f 100644 --- a/src/langbot/pkg/provider/tools/loaders/skill_authoring.py +++ b/src/langbot/pkg/provider/tools/loaders/skill_authoring.py @@ -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, diff --git a/tests/unit_tests/provider/test_skill_tools.py b/tests/unit_tests/provider/test_skill_tools.py index 2c2f49c24..ca6862407 100644 --- a/tests/unit_tests/provider/test_skill_tools.py +++ b/tests/unit_tests/provider/test_skill_tools.py @@ -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', } diff --git a/tests/unit_tests/test_skill_service.py b/tests/unit_tests/test_skill_service.py index a66e1472f..0882f63f3 100644 --- a/tests/unit_tests/test_skill_service.py +++ b/tests/unit_tests/test_skill_service.py @@ -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', diff --git a/uv.lock b/uv.lock index 556024c9a..3f1e83f19 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]