feat(agent): 接入 Skill Plugin 与知识库工具

This commit is contained in:
2026-08-27 23:53:04 +08:00
parent e045b9ce8b
commit cc17070e9e
6 changed files with 465 additions and 58 deletions
+147 -13
View File
@@ -1,7 +1,9 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from app.agent.tools import ToolExecutionContext, ToolRegistry 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): class ToolArguments(BaseModel):
@@ -17,6 +19,41 @@ class AddArguments(ToolArguments):
right: float 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]: async def echo(arguments: EchoArguments, _: ToolExecutionContext) -> dict[str, str]:
return {"text": arguments.text} return {"text": arguments.text}
@@ -25,22 +62,119 @@ async def add(arguments: AddArguments, _: ToolExecutionContext) -> dict[str, flo
return {"value": arguments.left + arguments.right} return {"value": arguments.left + arguments.right}
def register_builtin_tools(registry: ToolRegistry) -> None: 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( registry.register(
ToolDefinition( ToolDefinition(
name=name,
description=description,
parameters=arguments_model.model_json_schema(),
permission=permission,
),
arguments_model,
executor,
)
def register_builtin_tools(registry: ToolRegistry) -> None:
_register(
registry,
name="system.echo", name="system.echo",
description="Echo text for local Agent integration testing.", description="Echo text for local Agent integration testing.",
parameters=EchoArguments.model_json_schema(), arguments_model=EchoArguments,
), executor=echo,
EchoArguments,
echo,
) )
registry.register( _register(
ToolDefinition( registry,
name="math.add", name="math.add",
description="Add two numbers without external side effects.", description="Add two numbers without external side effects.",
parameters=AddArguments.model_json_schema(), arguments_model=AddArguments,
), executor=add,
AddArguments, )
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",
) )
+83 -7
View File
@@ -1,8 +1,11 @@
from __future__ import annotations
import asyncio import asyncio
import json import json
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import uuid4 from uuid import uuid4
from app.agent.permissions import PermissionManager, PermissionMode from app.agent.permissions import PermissionManager, PermissionMode
@@ -13,6 +16,7 @@ from app.contracts import (
AgentRun, AgentRun,
AgentRunCreateRequest, AgentRunCreateRequest,
AgentRunStatus, AgentRunStatus,
Citation,
Message, Message,
MessageRole, MessageRole,
ModelRequest, ModelRequest,
@@ -22,6 +26,9 @@ from app.contracts import (
from app.providers.registry import ProviderRegistry from app.providers.registry import ProviderRegistry
from app.providers.base import ProviderError from app.providers.base import ProviderError
if TYPE_CHECKING:
from app.extensions import AgentConfiguration, SkillRuntime
class AgentRunNotFoundError(LookupError): class AgentRunNotFoundError(LookupError):
pass pass
@@ -38,6 +45,8 @@ TERMINAL_STATUSES = {
class RunRecord: class RunRecord:
run: AgentRun run: AgentRun
request: AgentRunCreateRequest request: AgentRunCreateRequest
skill_config: AgentConfiguration | None = None
allowed_tools: list[str] = field(default_factory=list)
events: list[AgentEvent] = field(default_factory=list) events: list[AgentEvent] = field(default_factory=list)
subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set) subscribers: set[asyncio.Queue[AgentEvent]] = field(default_factory=set)
task: asyncio.Task[None] | None = None task: asyncio.Task[None] | None = None
@@ -49,14 +58,23 @@ class AgentRuntime:
providers: ProviderRegistry, providers: ProviderRegistry,
tools: ToolRegistry, tools: ToolRegistry,
permissions: PermissionManager, permissions: PermissionManager,
skills: SkillRuntime | None = None,
) -> None: ) -> None:
self.providers = providers self.providers = providers
self.tools = tools self.tools = tools
self.permissions = permissions self.permissions = permissions
self.skills = skills
self._records: dict[str, RunRecord] = {} self._records: dict[str, RunRecord] = {}
async def create_run(self, request: AgentRunCreateRequest) -> AgentRun: 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) now = datetime.now(timezone.utc)
run = AgentRun( run = AgentRun(
run_id=f"run_{uuid4().hex}", run_id=f"run_{uuid4().hex}",
@@ -70,7 +88,19 @@ class AgentRuntime:
created_at=now, created_at=now,
updated_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 self._records[run.run_id] = record
record.task = asyncio.create_task(self._execute(record), name=run.run_id) record.task = asyncio.create_task(self._execute(record), name=run.run_id)
return run.model_copy(deep=True) return run.model_copy(deep=True)
@@ -157,7 +187,7 @@ class AgentRuntime:
) )
messages = [Message(role=MessageRole.user, content=record.request.input)] 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 provider = self.providers.get(record.request.provider_id).adapter
for step in range(1, record.request.max_steps + 1): for step in range(1, record.request.max_steps + 1):
@@ -167,9 +197,10 @@ class AgentRuntime:
ModelRequest( ModelRequest(
provider_id=record.request.provider_id, provider_id=record.request.provider_id,
model=record.request.model, model=record.request.model,
system=(record.skill_config.system_prompt if record.skill_config else None),
messages=messages, messages=messages,
tools=allowed_tools, tools=allowed_tools,
metadata=record.request.metadata, metadata=self._request_metadata(record),
) )
) )
record.run.token_usage += turn.input_tokens + turn.output_tokens record.run.token_usage += turn.input_tokens + turn.output_tokens
@@ -197,9 +228,16 @@ class AgentRuntime:
messages.append( messages.append(
Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls) Message(role=MessageRole.assistant, content=turn.text or "", tool_calls=calls)
) )
for call in calls: semaphore = asyncio.Semaphore(record.request.max_concurrent_tools)
result = await self._execute_tool(record, call)
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) record.run.tool_results.append(result)
self._collect_citations(record, result)
messages.append( messages.append(
Message( Message(
role=MessageRole.tool, role=MessageRole.tool,
@@ -234,7 +272,7 @@ class AgentRuntime:
except ToolNotFoundError: except ToolNotFoundError:
registered = None 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( result = ToolResult(
tool_call_id=call.tool_call_id, tool_call_id=call.tool_call_id,
name=call.name, name=call.name,
@@ -246,6 +284,16 @@ class AgentRuntime:
return result return result
permission = registered.definition.permission if registered else None 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) mode = self.permissions.mode_for(permission)
if mode == PermissionMode.deny: if mode == PermissionMode.deny:
result = self._permission_denied(call) result = self._permission_denied(call)
@@ -348,6 +396,34 @@ class AgentRuntime:
for queue in record.subscribers: for queue in record.subscribers:
queue.put_nowait(event) 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: def _get_record(self, run_id: str) -> RunRecord:
try: try:
return self._records[run_id] return self._records[run_id]
+20 -1
View File
@@ -3,6 +3,8 @@ from dataclasses import dataclass
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
from app.agent.builtin_tools import register_builtin_tools from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType 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 import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import EnvironmentCredentialResolver from app.providers.credentials import EnvironmentCredentialResolver
@@ -13,6 +15,8 @@ class ApplicationContainer:
provider_factory: ProviderFactory provider_factory: ProviderFactory
tools: ToolRegistry tools: ToolRegistry
permissions: PermissionManager permissions: PermissionManager
skills: SkillRuntime
plugins: PluginRuntime
agent: AgentRuntime agent: AgentRuntime
@@ -38,14 +42,29 @@ def build_container() -> ApplicationContainer:
tools = ToolRegistry() tools = ToolRegistry()
register_builtin_tools(tools) 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() policy = PermissionPolicy()
permissions = PermissionManager(policy) permissions = PermissionManager(policy)
agent = AgentRuntime(providers=providers, tools=tools, permissions=permissions) agent = AgentRuntime(
providers=providers,
tools=tools,
permissions=permissions,
skills=skills,
)
return ApplicationContainer( return ApplicationContainer(
providers=providers, providers=providers,
provider_factory=provider_factory, provider_factory=provider_factory,
tools=tools, tools=tools,
permissions=permissions, permissions=permissions,
skills=skills,
plugins=plugins,
agent=agent, agent=agent,
) )
+30 -28
View File
@@ -49,6 +49,7 @@ from app.contracts import (
from app.agent import AgentRunNotFoundError from app.agent import AgentRunNotFoundError
from app.container import container from app.container import container
from app.errors import ApiError, not_implemented from app.errors import ApiError, not_implemented
from app.extensions import ExtensionError
from app.providers.registry import ProviderNotFoundError from app.providers.registry import ProviderNotFoundError
from app.providers.factory import UnsupportedProviderError from app.providers.factory import UnsupportedProviderError
from app.retrieval.engine import engine from app.retrieval.engine import engine
@@ -102,6 +103,13 @@ def configurable_provider_or_404(provider_id: str):
) from exc ) 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 # Notes
@router.get("/notes", response_model=NoteListResponse, tags=["Notes"]) @router.get("/notes", response_model=NoteListResponse, tags=["Notes"])
async def list_notes( async def list_notes(
@@ -203,18 +211,19 @@ async def list_agent_runs(
"/agent/runs", "/agent/runs",
response_model=AgentRun, response_model=AgentRun,
status_code=202, status_code=202,
responses=not_implemented_response,
tags=["Agent"], tags=["Agent"],
) )
async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun: async def create_agent_run(request: AgentRunCreateRequest) -> AgentRun:
provider_or_404(request.provider_id) provider_or_404(request.provider_id)
try:
return await container.agent.create_run(request) 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( @router.get(
"/agent/runs/{run_id}", "/agent/runs/{run_id}",
response_model=AgentRun, response_model=AgentRun,
responses=not_implemented_response,
tags=["Agent"], tags=["Agent"],
) )
async def get_agent_run(run_id: str) -> AgentRun: async def get_agent_run(run_id: str) -> AgentRun:
@@ -224,7 +233,6 @@ async def get_agent_run(run_id: str) -> AgentRun:
@router.post( @router.post(
"/agent/runs/{run_id}/cancel", "/agent/runs/{run_id}/cancel",
response_model=OperationResponse, response_model=OperationResponse,
responses=not_implemented_response,
tags=["Agent"], tags=["Agent"],
) )
async def cancel_agent_run(run_id: str) -> OperationResponse: async def cancel_agent_run(run_id: str) -> OperationResponse:
@@ -261,7 +269,6 @@ async def agent_events(run_id: str) -> StreamingResponse:
@router.post( @router.post(
"/agent/runs/{run_id}/permissions/{request_id}", "/agent/runs/{run_id}/permissions/{request_id}",
response_model=OperationResponse, response_model=OperationResponse,
responses=not_implemented_response,
tags=["Agent"], tags=["Agent"],
) )
async def decide_agent_permission( async def decide_agent_permission(
@@ -288,112 +295,107 @@ async def list_tools() -> ToolListResponse:
# Skills # Skills
@router.get("/skills", response_model=SkillListResponse, tags=["Skills"]) @router.get("/skills", response_model=SkillListResponse, tags=["Skills"])
async def list_skills() -> SkillListResponse: async def list_skills() -> SkillListResponse:
return SkillListResponse() return SkillListResponse(items=container.skills.list())
@router.get( @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: 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( @router.post(
"/skills/install", "/skills/install",
response_model=Skill, response_model=Skill,
status_code=202, status_code=202,
responses=not_implemented_response,
tags=["Skills"], tags=["Skills"],
) )
async def install_skill(_: ExtensionInstallRequest) -> Skill: async def install_skill(request: ExtensionInstallRequest) -> Skill:
not_implemented("skills.install") return extension_call(lambda: container.skills.install(request.package_path))
@router.post( @router.post(
"/skills/{skill_id}/enable", "/skills/{skill_id}/enable",
response_model=Skill, response_model=Skill,
responses=not_implemented_response,
tags=["Skills"], tags=["Skills"],
) )
async def enable_skill(skill_id: str) -> Skill: 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( @router.post(
"/skills/{skill_id}/disable", "/skills/{skill_id}/disable",
response_model=Skill, response_model=Skill,
responses=not_implemented_response,
tags=["Skills"], tags=["Skills"],
) )
async def disable_skill(skill_id: str) -> Skill: 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( @router.delete(
"/skills/{skill_id}", "/skills/{skill_id}",
response_model=OperationResponse, response_model=OperationResponse,
responses=not_implemented_response,
tags=["Skills"], tags=["Skills"],
) )
async def uninstall_skill(skill_id: str) -> OperationResponse: 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 # Plugins
@router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"]) @router.get("/plugins", response_model=PluginListResponse, tags=["Plugins"])
async def list_plugins() -> PluginListResponse: async def list_plugins() -> PluginListResponse:
return PluginListResponse() return PluginListResponse(items=container.plugins.list())
@router.get( @router.get(
"/plugins/{plugin_id}", "/plugins/{plugin_id}",
response_model=Plugin, response_model=Plugin,
responses=not_implemented_response,
tags=["Plugins"], tags=["Plugins"],
) )
async def get_plugin(plugin_id: str) -> Plugin: 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( @router.post(
"/plugins/install", "/plugins/install",
response_model=Plugin, response_model=Plugin,
status_code=202, status_code=202,
responses=not_implemented_response,
tags=["Plugins"], tags=["Plugins"],
) )
async def install_plugin(_: ExtensionInstallRequest) -> Plugin: async def install_plugin(request: ExtensionInstallRequest) -> Plugin:
not_implemented("plugins.install") return extension_call(lambda: container.plugins.install(request.package_path))
@router.post( @router.post(
"/plugins/{plugin_id}/enable", "/plugins/{plugin_id}/enable",
response_model=Plugin, response_model=Plugin,
responses=not_implemented_response,
tags=["Plugins"], tags=["Plugins"],
) )
async def enable_plugin(plugin_id: str) -> Plugin: 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( @router.post(
"/plugins/{plugin_id}/disable", "/plugins/{plugin_id}/disable",
response_model=Plugin, response_model=Plugin,
responses=not_implemented_response,
tags=["Plugins"], tags=["Plugins"],
) )
async def disable_plugin(plugin_id: str) -> Plugin: 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( @router.delete(
"/plugins/{plugin_id}", "/plugins/{plugin_id}",
response_model=OperationResponse, response_model=OperationResponse,
responses=not_implemented_response,
tags=["Plugins"], tags=["Plugins"],
) )
async def uninstall_plugin(plugin_id: str) -> OperationResponse: 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 # Providers
+5 -3
View File
@@ -19,7 +19,7 @@ def test_service_status() -> None:
assert response.status == "ok" 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)) notes = asyncio.run(list_notes(limit=20, offset=0, folder=None, tag=None))
skills = asyncio.run(list_skills()) skills = asyncio.run(list_skills())
plugins = asyncio.run(list_plugins()) plugins = asyncio.run(list_plugins())
@@ -28,8 +28,10 @@ def test_query_shells_are_empty_and_typed() -> None:
assert notes.items == [] assert notes.items == []
assert notes.page.limit == 20 assert notes.page.limit == 20
assert skills.items == [] assert [skill.manifest.skill_id for skill in skills.items] == ["knowledge-assistant"]
assert plugins.items == [] 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 [provider.provider_id for provider in providers.items] == ["mock"]
assert index.status == "idle" assert index.status == "idle"
+174
View File
@@ -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"