diff --git a/backend/app/agent/builtin_tools.py b/backend/app/agent/builtin_tools.py index 2ffafbf..042efb3 100644 --- a/backend/app/agent/builtin_tools.py +++ b/backend/app/agent/builtin_tools.py @@ -1,7 +1,9 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.agent.tools import ToolExecutionContext, ToolRegistry -from app.contracts import ToolDefinition +from app.contracts import SearchMode, SearchRequest, ToolDefinition +from app.retrieval.engine import engine +from app.services import note_service class ToolArguments(BaseModel): @@ -17,6 +19,41 @@ class AddArguments(ToolArguments): right: float +class NoteSearchArguments(ToolArguments): + query: str = Field(min_length=1) + mode: SearchMode = SearchMode.hybrid + folders: list[str] = Field(default_factory=list) + note_ids: list[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) + limit: int = Field(default=10, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +class NoteReadArguments(ToolArguments): + note_id: str = Field(min_length=1) + + +class NoteCreateArguments(ToolArguments): + title: str = Field(min_length=1) + markdown: str = "" + folder: str | None = None + tags: list[str] = Field(default_factory=list) + + +class NoteUpdateArguments(ToolArguments): + note_id: str = Field(min_length=1) + title: str | None = None + markdown: str | None = None + tags: list[str] | None = None + + +class NoteListArguments(ToolArguments): + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) + folder: str | None = None + tag: str | None = None + + async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]: return {"text": arguments.text} @@ -25,22 +62,119 @@ async def add(arguments: AddArguments, _: ToolExecutionContext) -> dict[str, flo return {"value": arguments.left + arguments.right} +async def search_notes(arguments: NoteSearchArguments, _: ToolExecutionContext) -> dict: + request = SearchRequest(**arguments.model_dump(), include_snippet=True) + return (await engine.search(request)).model_dump(mode="json") + + +async def read_note(arguments: NoteReadArguments, _: ToolExecutionContext) -> dict: + note = await note_service.get_note(arguments.note_id) + if note is None: + raise LookupError(f"Note does not exist: {arguments.note_id}") + return note.model_dump(mode="json") + + +async def create_note(arguments: NoteCreateArguments, _: ToolExecutionContext) -> dict: + note = await note_service.create_note(**arguments.model_dump()) + return note.model_dump(mode="json") + + +async def update_note(arguments: NoteUpdateArguments, _: ToolExecutionContext) -> dict: + values = arguments.model_dump() + note_id = values.pop("note_id") + note = await note_service.update_note(note_id, **values) + return note.model_dump(mode="json") + + +def list_notes(arguments: NoteListArguments, _: ToolExecutionContext) -> dict: + items, total = note_service.list_notes(**arguments.model_dump()) + return { + "items": [item.model_dump(mode="json") for item in items], + "page": {"total": total, "limit": arguments.limit, "offset": arguments.offset}, + } + + +def _register( + registry: ToolRegistry, + *, + name: str, + description: str, + arguments_model: type[BaseModel], + executor, + permission: str | None = None, +) -> None: + registry.register( + ToolDefinition( + name=name, + description=description, + parameters=arguments_model.model_json_schema(), + permission=permission, + ), + arguments_model, + executor, + ) + + def register_builtin_tools(registry: ToolRegistry) -> None: - registry.register( - ToolDefinition( - name="system.echo", - description="Echo text for local Agent integration testing.", - parameters=EchoArguments.model_json_schema(), - ), - EchoArguments, - echo, + _register( + registry, + name="system.echo", + description="Echo text for local Agent integration testing.", + arguments_model=EchoArguments, + executor=echo, ) - registry.register( - ToolDefinition( - name="math.add", - description="Add two numbers without external side effects.", - parameters=AddArguments.model_json_schema(), - ), - AddArguments, - add, + _register( + registry, + name="math.add", + description="Add two numbers without external side effects.", + arguments_model=AddArguments, + executor=add, + ) + _register( + registry, + name="notes.search", + description="Search indexed notes and return snippets with citations.", + arguments_model=NoteSearchArguments, + executor=search_notes, + permission="notes.search", + ) + _register( + registry, + name="rag.search", + description="Retrieve relevant note blocks for Agent context with citations.", + arguments_model=NoteSearchArguments, + executor=search_notes, + permission="notes.search", + ) + _register( + registry, + name="notes.read", + description="Read a note and its parsed blocks by note_id.", + arguments_model=NoteReadArguments, + executor=read_note, + permission="notes.read", + ) + _register( + registry, + name="notes.create", + description="Create a Markdown note in the current Vault.", + arguments_model=NoteCreateArguments, + executor=create_note, + permission="notes.write", + ) + _register( + registry, + name="notes.update", + description="Update an existing Markdown note.", + arguments_model=NoteUpdateArguments, + executor=update_note, + permission="notes.write", + ) + _register( + registry, + name="notes.list", + description="List note summaries with folder and tag filters.", + arguments_model=NoteListArguments, + executor=list_notes, + permission="notes.read", ) diff --git a/backend/app/agent/runtime.py b/backend/app/agent/runtime.py index 4fe122a..e8eab61 100644 --- a/backend/app/agent/runtime.py +++ b/backend/app/agent/runtime.py @@ -1,8 +1,11 @@ +from __future__ import annotations + import asyncio import json from collections.abc import AsyncIterator from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import TYPE_CHECKING from uuid import uuid4 from app.agent.permissions import PermissionManager, PermissionMode @@ -13,6 +16,7 @@ from app.contracts import ( AgentRun, AgentRunCreateRequest, AgentRunStatus, + Citation, Message, MessageRole, ModelRequest, @@ -22,6 +26,9 @@ from app.contracts import ( from app.providers.registry import ProviderRegistry from app.providers.base import ProviderError +if TYPE_CHECKING: + from app.extensions import AgentConfiguration, SkillRuntime + class AgentRunNotFoundError(LookupError): pass @@ -38,6 +45,8 @@ TERMINAL_STATUSES = { class RunRecord: run: AgentRun request: AgentRunCreateRequest + skill_config: AgentConfiguration | None = None + allowed_tools: list[str] = field(default_factory=list) events: list[AgentEvent] = field(default_factory=list) subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set) task: asyncio.Task[None] | None = None @@ -49,14 +58,23 @@ class AgentRuntime: providers: ProviderRegistry, tools: ToolRegistry, permissions: PermissionManager, + skills: SkillRuntime | None = None, ) -> None: self.providers = providers self.tools = tools self.permissions = permissions + self.skills = skills self._records: dict[str, RunRecord] = {} async def create_run(self, request: AgentRunCreateRequest) -> AgentRun: - self.providers.get(request.provider_id) + provider = self.providers.get(request.provider_id) + skill_config = None + if request.skill_id: + if self.skills is None: + raise RuntimeError("Skill Runtime is not configured.") + skill_config = self.skills.build_agent_configuration( + request.skill_id, provider.config.capabilities + ) now = datetime.now(timezone.utc) run = AgentRun( run_id=f"run_{uuid4().hex}", @@ -70,7 +88,19 @@ class AgentRuntime: created_at=now, updated_at=now, ) - record = RunRecord(run=run, request=request) + allowed_tools = list(request.allowed_tools) + if skill_config is not None: + allowed_tools = ( + [name for name in skill_config.allowed_tools if name in allowed_tools] + if allowed_tools + else list(skill_config.allowed_tools) + ) + record = RunRecord( + run=run, + request=request, + skill_config=skill_config, + allowed_tools=allowed_tools, + ) self._records[run.run_id] = record record.task = asyncio.create_task(self._execute(record), name=run.run_id) return run.model_copy(deep=True) @@ -157,7 +187,7 @@ class AgentRuntime: ) messages = [Message(role=MessageRole.user, content=record.request.input)] - allowed_tools = self.tools.definitions(record.request.allowed_tools) + allowed_tools = self.tools.definitions(record.allowed_tools) provider = self.providers.get(record.request.provider_id).adapter for step in range(1, record.request.max_steps + 1): @@ -167,9 +197,10 @@ class AgentRuntime: ModelRequest( provider_id=record.request.provider_id, model=record.request.model, + system=(record.skill_config.system_prompt if record.skill_config else None), messages=messages, tools=allowed_tools, - metadata=record.request.metadata, + metadata=self._request_metadata(record), ) ) record.run.token_usage += turn.input_tokens + turn.output_tokens @@ -197,9 +228,16 @@ class AgentRuntime: messages.append( Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls) ) - for call in calls: - result = await self._execute_tool(record, call) + semaphore = asyncio.Semaphore(record.request.max_concurrent_tools) + + async def execute(call: ToolCall) -> ToolResult: + async with semaphore: + return await self._execute_tool(record, call) + + results = await asyncio.gather(*(execute(call) for call in calls)) + for call, result in zip(calls, results): record.run.tool_results.append(result) + self._collect_citations(record, result) messages.append( Message( role=MessageRole.tool, @@ -234,7 +272,7 @@ class AgentRuntime: except ToolNotFoundError: registered = None - if registered is not None and call.name not in record.request.allowed_tools: + if registered is not None and call.name not in record.allowed_tools: result = ToolResult( tool_call_id=call.tool_call_id, name=call.name, @@ -246,6 +284,16 @@ class AgentRuntime: return result permission = registered.definition.permission if registered else None + if permission == "network.request" and not record.request.allow_network: + result = ToolResult( + tool_call_id=call.tool_call_id, + name=call.name, + success=False, + error_code="NETWORK_NOT_ALLOWED", + error_message="Agent run does not allow network tools.", + ) + self._publish(record, AgentEventType.tool_result, result.model_dump(mode="json")) + return result mode = self.permissions.mode_for(permission) if mode == PermissionMode.deny: result = self._permission_denied(call) @@ -348,6 +396,34 @@ class AgentRuntime: for queue in record.subscribers: queue.put_nowait(event) + @staticmethod + def _request_metadata(record: RunRecord) -> dict[str, object]: + metadata = dict(record.request.metadata) + if record.skill_config is not None: + metadata["skill_id"] = record.skill_config.skill_id + metadata["retrieval"] = record.skill_config.retrieval.model_dump(mode="json") + return metadata + + def _collect_citations(self, record: RunRecord, result: ToolResult) -> None: + if not result.success or not isinstance(result.output, dict): + return + items = result.output.get("items") + if not isinstance(items, list): + return + known = {citation.citation_id for citation in record.run.citations} + for item in items: + if not isinstance(item, dict) or not isinstance(item.get("citation"), dict): + continue + try: + citation = Citation.model_validate(item["citation"]) + except ValueError: + continue + if citation.citation_id in known: + continue + known.add(citation.citation_id) + record.run.citations.append(citation) + self._publish(record, AgentEventType.citation, citation.model_dump(mode="json")) + def _get_record(self, run_id: str) -> RunRecord: try: return self._records[run_id] diff --git a/backend/app/container.py b/backend/app/container.py index cdf05aa..78aa1dd 100644 --- a/backend/app/container.py +++ b/backend/app/container.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent.builtin_tools import register_builtin_tools from app.contracts import ModelCapability, ProviderConfig, ProviderType +from app.config import BACKEND_DIR +from app.extensions import PluginRuntime, SkillRuntime from app.providers import MockProvider, ProviderFactory, ProviderRegistry from app.providers.credentials import EnvironmentCredentialResolver @@ -13,6 +15,8 @@ class ApplicationContainer: provider_factory: ProviderFactory tools: ToolRegistry permissions: PermissionManager + skills: SkillRuntime + plugins: PluginRuntime agent: AgentRuntime @@ -38,14 +42,29 @@ def build_container() -> ApplicationContainer: tools = ToolRegistry() register_builtin_tools(tools) + plugins = PluginRuntime(tools) + plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools") + plugins.enable("text-tools") + + skills = SkillRuntime(tools) + skills.install(BACKEND_DIR / "extensions" / "skills" / "knowledge-assistant") + skills.enable("knowledge-assistant") + policy = PermissionPolicy() permissions = PermissionManager(policy) - agent = AgentRuntime(providers=providers, tools=tools, permissions=permissions) + agent = AgentRuntime( + providers=providers, + tools=tools, + permissions=permissions, + skills=skills, + ) return ApplicationContainer( providers=providers, provider_factory=provider_factory, tools=tools, permissions=permissions, + skills=skills, + plugins=plugins, agent=agent, ) diff --git a/backend/app/routes.py b/backend/app/routes.py index 3eca5e9..12c3e63 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -49,6 +49,7 @@ from app.contracts import ( from app.agent import AgentRunNotFoundError from app.container import container from app.errors import ApiError, not_implemented +from app.extensions import ExtensionError from app.providers.registry import ProviderNotFoundError from app.providers.factory import UnsupportedProviderError from app.retrieval.engine import engine @@ -102,6 +103,13 @@ def configurable_provider_or_404(provider_id: str): ) from exc +def extension_call(operation): + try: + return operation() + except ExtensionError as exc: + raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc + + # Notes @router.get("/notes", response_model=NoteListResponse, tags=["Notes"]) async def list_notes( @@ -203,18 +211,19 @@ async def list_agent_runs( "/agent/runs", response_model=AgentRun, status_code=202, - responses=not_implemented_response, tags=["Agent"], ) async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun: provider_or_404(request.provider_id) - return await container.agent.create_run(request) + try: + return await container.agent.create_run(request) + except ExtensionError as exc: + raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc @router.get( "/agent/runs/{run_id}", response_model=AgentRun, - responses=not_implemented_response, tags=["Agent"], ) async def get_agent_run(run_id: str) -> AgentRun: @@ -224,7 +233,6 @@ async def get_agent_run(run_id: str) -> AgentRun: @router.post( "/agent/runs/{run_id}/cancel", response_model=OperationResponse, - responses=not_implemented_response, tags=["Agent"], ) async def cancel_agent_run(run_id: str) -> OperationResponse: @@ -261,7 +269,6 @@ async def agent_events(run_id: str) -> StreamingResponse: @router.post( "/agent/runs/{run_id}/permissions/{request_id}", response_model=OperationResponse, - responses=not_implemented_response, tags=["Agent"], ) async def decide_agent_permission( @@ -288,112 +295,107 @@ async def list_tools() -> ToolListResponse: # Skills @router.get("/skills", response_model=SkillListResponse, tags=["Skills"]) async def list_skills() -> SkillListResponse: - return SkillListResponse() + return SkillListResponse(items=container.skills.list()) @router.get( - "/skills/{skill_id}", response_model=Skill, responses=not_implemented_response, tags=["Skills"] + "/skills/{skill_id}", response_model=Skill, tags=["Skills"] ) async def get_skill(skill_id: str) -> Skill: - not_implemented(f"skills.read:{skill_id}") + return extension_call(lambda: container.skills.get(skill_id)) @router.post( "/skills/install", response_model=Skill, status_code=202, - responses=not_implemented_response, tags=["Skills"], ) -async def install_skill(_: ExtensionInstallRequest) -> Skill: - not_implemented("skills.install") +async def install_skill(request: ExtensionInstallRequest) -> Skill: + return extension_call(lambda: container.skills.install(request.package_path)) @router.post( "/skills/{skill_id}/enable", response_model=Skill, - responses=not_implemented_response, tags=["Skills"], ) async def enable_skill(skill_id: str) -> Skill: - not_implemented(f"skills.enable:{skill_id}") + return extension_call(lambda: container.skills.enable(skill_id)) @router.post( "/skills/{skill_id}/disable", response_model=Skill, - responses=not_implemented_response, tags=["Skills"], ) async def disable_skill(skill_id: str) -> Skill: - not_implemented(f"skills.disable:{skill_id}") + return extension_call(lambda: container.skills.disable(skill_id)) @router.delete( "/skills/{skill_id}", response_model=OperationResponse, - responses=not_implemented_response, tags=["Skills"], ) async def uninstall_skill(skill_id: str) -> OperationResponse: - not_implemented(f"skills.uninstall:{skill_id}") + extension_call(lambda: container.skills.uninstall(skill_id)) + return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled") # Plugins @router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"]) async def list_plugins() -> PluginListResponse: - return PluginListResponse() + return PluginListResponse(items=container.plugins.list()) @router.get( "/plugins/{plugin_id}", response_model=Plugin, - responses=not_implemented_response, tags=["Plugins"], ) async def get_plugin(plugin_id: str) -> Plugin: - not_implemented(f"plugins.read:{plugin_id}") + return extension_call(lambda: container.plugins.get(plugin_id)) @router.post( "/plugins/install", response_model=Plugin, status_code=202, - responses=not_implemented_response, tags=["Plugins"], ) -async def install_plugin(_: ExtensionInstallRequest) -> Plugin: - not_implemented("plugins.install") +async def install_plugin(request: ExtensionInstallRequest) -> Plugin: + return extension_call(lambda: container.plugins.install(request.package_path)) @router.post( "/plugins/{plugin_id}/enable", response_model=Plugin, - responses=not_implemented_response, tags=["Plugins"], ) async def enable_plugin(plugin_id: str) -> Plugin: - not_implemented(f"plugins.enable:{plugin_id}") + return extension_call(lambda: container.plugins.enable(plugin_id)) @router.post( "/plugins/{plugin_id}/disable", response_model=Plugin, - responses=not_implemented_response, tags=["Plugins"], ) async def disable_plugin(plugin_id: str) -> Plugin: - not_implemented(f"plugins.disable:{plugin_id}") + return extension_call(lambda: container.plugins.disable(plugin_id)) @router.delete( "/plugins/{plugin_id}", response_model=OperationResponse, - responses=not_implemented_response, tags=["Plugins"], ) async def uninstall_plugin(plugin_id: str) -> OperationResponse: - not_implemented(f"plugins.uninstall:{plugin_id}") + plugin = extension_call(lambda: container.plugins.get(plugin_id)) + dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools) + extension_call(lambda: container.plugins.uninstall(plugin_id, dependent_skills)) + return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled") # Providers diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 1a374b8..b6949a4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -19,7 +19,7 @@ def test_service_status() -> None: assert response.status == "ok" -def test_query_shells_are_empty_and_typed() -> None: +def test_core_collections_are_typed() -> None: notes = asyncio.run(list_notes(limit=20, offset=0, folder=None, tag=None)) skills = asyncio.run(list_skills()) plugins = asyncio.run(list_plugins()) @@ -28,8 +28,10 @@ def test_query_shells_are_empty_and_typed() -> None: assert notes.items == [] assert notes.page.limit == 20 - assert skills.items == [] - assert plugins.items == [] + assert [skill.manifest.skill_id for skill in skills.items] == ["knowledge-assistant"] + assert skills.items[0].status == "ready" + assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"] + assert plugins.items[0].status == "ready" assert [provider.provider_id for provider in providers.items] == ["mock"] assert index.status == "idle" diff --git a/backend/tests/test_extension_core.py b/backend/tests/test_extension_core.py new file mode 100644 index 0000000..0f5ae07 --- /dev/null +++ b/backend/tests/test_extension_core.py @@ -0,0 +1,174 @@ +import asyncio + +import pytest + +from app.agent.permissions import PermissionMode +from app.agent.tools import ToolExecutionContext +from app.container import build_container +from app.contracts import ( + AgentRunCreateRequest, + AgentRunStatus, + SkillStatus, + ToolCall, +) +from app.extensions import ExtensionError +from app.services import note_service + + +def run(coroutine): + return asyncio.run(coroutine) + + +def test_bundled_plugin_registers_tool_and_skill_is_ready() -> None: + async def scenario() -> None: + container = build_container() + + plugin = container.plugins.get("text-tools") + skill = container.skills.get("knowledge-assistant") + definition = container.tools.get("text.uppercase").definition + result = await container.tools.execute( + ToolCall( + tool_call_id="call_uppercase", + name="text.uppercase", + arguments={"text": "hello plugin"}, + ), + ToolExecutionContext(run_id="run_extension_test"), + ) + + assert plugin.enabled is True and plugin.status == "ready" + assert skill.enabled is True and skill.status == SkillStatus.ready + assert definition.source == "plugin" + assert result.success is True + assert result.output == {"text": "HELLO PLUGIN"} + + run(scenario()) + + +def test_skill_drives_agent_and_can_call_plugin_tool() -> None: + async def scenario() -> None: + container = build_container() + created = await container.agent.create_run( + AgentRunCreateRequest( + input='/tool text.uppercase {"text":"skill plugin"}', + provider_id="mock", + model="mock-1", + skill_id="knowledge-assistant", + ) + ) + + completed = await container.agent.wait(created.run_id) + + assert completed.status == AgentRunStatus.completed + assert completed.tool_results[0].success is True + assert completed.tool_results[0].output == {"text": "SKILL PLUGIN"} + + run(scenario()) + + +def test_plugin_disable_updates_skill_dependency_status() -> None: + container = build_container() + + disabled = container.plugins.disable("text-tools") + skill = container.skills.get("knowledge-assistant") + + assert disabled.status == "disabled" + assert not container.tools.contains("text.uppercase") + assert skill.status == SkillStatus.dependency_missing + assert skill.missing_dependencies == ["text.uppercase"] + + with pytest.raises(ExtensionError) as exc: + container.skills.build_agent_configuration( + "knowledge-assistant", + container.providers.get("mock").config.capabilities, + ) + assert exc.value.code == "SKILL_NOT_READY" + + container.plugins.enable("text-tools") + assert container.skills.get("knowledge-assistant").status == SkillStatus.ready + + +def test_enabled_skill_blocks_plugin_uninstall() -> None: + container = build_container() + plugin = container.plugins.get("text-tools") + dependencies = container.skills.depending_on_tools(plugin.manifest.contributes.tools) + + with pytest.raises(ExtensionError) as exc: + container.plugins.uninstall("text-tools", dependencies) + + assert exc.value.code == "PLUGIN_IN_USE" + assert exc.value.details["skills"] == ["knowledge-assistant"] + + +def test_agent_note_search_tool_collects_citations() -> None: + async def scenario() -> None: + container = build_container() + await note_service.create_note( + title="Agent 检索", + markdown="# Agent\n\nAgent 可以通过工具检索本地知识库。", + folder="", + tags=["agent"], + ) + created = await container.agent.create_run( + AgentRunCreateRequest( + input='/tool notes.search {"query":"本地知识库","mode":"fts"}', + provider_id="mock", + model="mock-1", + skill_id="knowledge-assistant", + ) + ) + + completed = await container.agent.wait(created.run_id) + + assert completed.status == AgentRunStatus.completed + assert completed.tool_results[0].success is True + assert completed.citations + assert completed.citations[0].file_path == "Agent 检索.md" + + run(scenario()) + + +def test_network_tool_requires_run_level_network_permission() -> None: + async def scenario() -> None: + container = build_container() + tool = container.tools.get("system.echo") + tool.definition.permission = "network.request" + container.permissions.policy.set_rule("network.request", PermissionMode.allow) + + created = await container.agent.create_run( + AgentRunCreateRequest( + input='/tool system.echo {"text":"network"}', + provider_id="mock", + model="mock-1", + allowed_tools=["system.echo"], + allow_network=False, + ) + ) + completed = await container.agent.wait(created.run_id) + + assert completed.tool_results[0].success is False + assert completed.tool_results[0].error_code == "NETWORK_NOT_ALLOWED" + + run(scenario()) + + +def test_skill_install_reports_missing_tool_dependency(tmp_path) -> None: + package = tmp_path / "missing-tool-skill" + package.mkdir() + (package / "skill.yaml").write_text( + """ +id: missing-tool +name: Missing Tool +version: 1.0.0 +tools: [plugin.not-installed] +""".strip(), + encoding="utf-8", + ) + container = build_container() + + installed = container.skills.install(package) + + assert installed.status == SkillStatus.dependency_missing + assert installed.missing_dependencies == ["plugin.not-installed"] + with pytest.raises(ExtensionError) as exc: + container.skills.enable("missing-tool") + assert exc.value.code == "SKILL_DEPENDENCY_MISSING"