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
+152 -18
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.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",
)
+83 -7
View File
@@ -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]
+20 -1
View File
@@ -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,
)
+31 -29
View File
@@ -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
+5 -3
View File
@@ -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"
+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"