feat(extension): 实现 Skill 与 Plugin Runtime 基础
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
|
||||
|
||||
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||
from app.contracts import (
|
||||
ModelCapability,
|
||||
Plugin,
|
||||
PluginManifest,
|
||||
PluginStatus,
|
||||
RetrievalConfig,
|
||||
Skill,
|
||||
SkillManifest,
|
||||
SkillStatus,
|
||||
ToolDefinition,
|
||||
)
|
||||
|
||||
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
|
||||
|
||||
class ExtensionError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 422,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentConfiguration:
|
||||
skill_id: str
|
||||
system_prompt: str
|
||||
allowed_tools: list[str]
|
||||
permissions: list[str]
|
||||
retrieval: RetrievalConfig
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _SkillRecord:
|
||||
skill: Skill
|
||||
prompt: str
|
||||
package_path: Path
|
||||
|
||||
|
||||
class SkillRuntime:
|
||||
"""声明式 Skill 生命周期;Skill 只生成 Agent 配置,不执行第三方代码。"""
|
||||
|
||||
def __init__(self, tools: ToolRegistry) -> None:
|
||||
self.tools = tools
|
||||
self._records: dict[str, _SkillRecord] = {}
|
||||
|
||||
def install(self, package_path: str | Path) -> Skill:
|
||||
root = _package_dir(package_path)
|
||||
raw = _read_yaml(root / "skill.yaml")
|
||||
if "id" in raw and "skill_id" not in raw:
|
||||
raw["skill_id"] = raw.pop("id")
|
||||
try:
|
||||
manifest = SkillManifest.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("skill", exc) from exc
|
||||
_validate_id("skill", manifest.skill_id)
|
||||
if manifest.skill_id in self._records:
|
||||
raise ExtensionError(
|
||||
"SKILL_ALREADY_INSTALLED",
|
||||
f"Skill is already installed: {manifest.skill_id}",
|
||||
status_code=409,
|
||||
)
|
||||
prompt_path = root / "prompt.md"
|
||||
prompt = prompt_path.read_text(encoding="utf-8") if prompt_path.exists() else ""
|
||||
record = _SkillRecord(
|
||||
skill=Skill(manifest=manifest, status=SkillStatus.installed),
|
||||
prompt=prompt,
|
||||
package_path=root,
|
||||
)
|
||||
self._records[manifest.skill_id] = record
|
||||
self._refresh(record)
|
||||
return record.skill.model_copy(deep=True)
|
||||
|
||||
def list(self) -> list[Skill]:
|
||||
for record in self._records.values():
|
||||
self._refresh(record)
|
||||
return [record.skill.model_copy(deep=True) for record in self._records.values()]
|
||||
|
||||
def get(self, skill_id: str) -> Skill:
|
||||
record = self._record(skill_id)
|
||||
self._refresh(record)
|
||||
return record.skill.model_copy(deep=True)
|
||||
|
||||
def enable(self, skill_id: str) -> Skill:
|
||||
record = self._record(skill_id)
|
||||
missing = self._missing_tools(record.skill.manifest)
|
||||
if missing:
|
||||
record.skill.enabled = False
|
||||
record.skill.status = SkillStatus.dependency_missing
|
||||
record.skill.missing_dependencies = missing
|
||||
raise ExtensionError(
|
||||
"SKILL_DEPENDENCY_MISSING",
|
||||
f"Skill has missing tools: {', '.join(missing)}",
|
||||
status_code=409,
|
||||
details={"skill_id": skill_id, "missing_tools": missing},
|
||||
)
|
||||
undeclared = self._undeclared_permissions(record.skill.manifest)
|
||||
if undeclared:
|
||||
record.skill.enabled = False
|
||||
record.skill.status = SkillStatus.permission_required
|
||||
raise ExtensionError(
|
||||
"SKILL_PERMISSION_UNDECLARED",
|
||||
"Skill tools require permissions missing from the manifest.",
|
||||
status_code=409,
|
||||
details={"skill_id": skill_id, "permissions": undeclared},
|
||||
)
|
||||
record.skill.enabled = True
|
||||
record.skill.status = SkillStatus.ready
|
||||
record.skill.missing_dependencies = []
|
||||
return record.skill.model_copy(deep=True)
|
||||
|
||||
def disable(self, skill_id: str) -> Skill:
|
||||
record = self._record(skill_id)
|
||||
record.skill.enabled = False
|
||||
record.skill.status = SkillStatus.disabled
|
||||
return record.skill.model_copy(deep=True)
|
||||
|
||||
def uninstall(self, skill_id: str) -> None:
|
||||
self._record(skill_id)
|
||||
del self._records[skill_id]
|
||||
|
||||
def build_agent_configuration(
|
||||
self, skill_id: str, provider_capabilities: list[ModelCapability]
|
||||
) -> AgentConfiguration:
|
||||
record = self._record(skill_id)
|
||||
self._refresh(record)
|
||||
if not record.skill.enabled or record.skill.status != SkillStatus.ready:
|
||||
raise ExtensionError(
|
||||
"SKILL_NOT_READY",
|
||||
f"Skill is not enabled and ready: {skill_id}",
|
||||
status_code=409,
|
||||
)
|
||||
required = set(record.skill.manifest.model.required_capabilities)
|
||||
missing_capabilities = sorted(cap.value for cap in required - set(provider_capabilities))
|
||||
if missing_capabilities:
|
||||
raise ExtensionError(
|
||||
"SKILL_MODEL_CAPABILITY_MISSING",
|
||||
"Provider does not satisfy the Skill model requirements.",
|
||||
status_code=409,
|
||||
details={"skill_id": skill_id, "missing_capabilities": missing_capabilities},
|
||||
)
|
||||
return AgentConfiguration(
|
||||
skill_id=skill_id,
|
||||
system_prompt=record.prompt,
|
||||
allowed_tools=list(record.skill.manifest.tools),
|
||||
permissions=list(record.skill.manifest.permissions),
|
||||
retrieval=record.skill.manifest.retrieval.model_copy(deep=True),
|
||||
)
|
||||
|
||||
def depending_on_tools(self, names: list[str]) -> list[str]:
|
||||
target = set(names)
|
||||
return [
|
||||
skill_id
|
||||
for skill_id, record in self._records.items()
|
||||
if record.skill.enabled and target.intersection(record.skill.manifest.tools)
|
||||
]
|
||||
|
||||
def _record(self, skill_id: str) -> _SkillRecord:
|
||||
try:
|
||||
return self._records[skill_id]
|
||||
except KeyError as exc:
|
||||
raise ExtensionError(
|
||||
"SKILL_NOT_FOUND", f"Skill is not installed: {skill_id}", status_code=404
|
||||
) from exc
|
||||
|
||||
def _missing_tools(self, manifest: SkillManifest) -> list[str]:
|
||||
return [name for name in manifest.tools if not self.tools.contains(name)]
|
||||
|
||||
def _undeclared_permissions(self, manifest: SkillManifest) -> list[str]:
|
||||
declared = set(manifest.permissions)
|
||||
required = {
|
||||
self.tools.get(name).definition.permission
|
||||
for name in manifest.tools
|
||||
if self.tools.contains(name) and self.tools.get(name).definition.permission
|
||||
}
|
||||
return sorted(required - declared)
|
||||
|
||||
def _refresh(self, record: _SkillRecord) -> None:
|
||||
missing = self._missing_tools(record.skill.manifest)
|
||||
record.skill.missing_dependencies = missing
|
||||
if missing:
|
||||
record.skill.status = SkillStatus.dependency_missing
|
||||
elif self._undeclared_permissions(record.skill.manifest):
|
||||
record.skill.status = SkillStatus.permission_required
|
||||
elif record.skill.enabled:
|
||||
record.skill.status = SkillStatus.ready
|
||||
elif record.skill.status != SkillStatus.installed:
|
||||
record.skill.status = SkillStatus.disabled
|
||||
|
||||
|
||||
class DeclarativeToolSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
description: str
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
permission: str | None = None
|
||||
handler: Literal["echo", "uppercase"]
|
||||
|
||||
|
||||
class DeclarativePluginHost:
|
||||
"""第一阶段内置 Host:仅执行宿主实现的白名单 handler,不加载插件代码。"""
|
||||
|
||||
async def execute(
|
||||
self, handler: str, arguments: BaseModel, _: ToolExecutionContext
|
||||
) -> Any:
|
||||
values = arguments.model_dump()
|
||||
if handler == "echo":
|
||||
return values
|
||||
if handler == "uppercase":
|
||||
return {"text": str(values.get("text", "")).upper()}
|
||||
raise ExtensionError("PLUGIN_HANDLER_UNSUPPORTED", f"Unsupported handler: {handler}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PluginRecord:
|
||||
plugin: Plugin
|
||||
tools: list[DeclarativeToolSpec]
|
||||
package_path: Path
|
||||
registered_tools: list[str]
|
||||
|
||||
|
||||
class PluginRuntime:
|
||||
"""Plugin Manifest、生命周期及 Tool Contribution 注册。"""
|
||||
|
||||
def __init__(self, tools: ToolRegistry, host: DeclarativePluginHost | None = None) -> None:
|
||||
self.registry = tools
|
||||
self.host = host or DeclarativePluginHost()
|
||||
self._records: dict[str, _PluginRecord] = {}
|
||||
|
||||
def install(self, package_path: str | Path) -> Plugin:
|
||||
root = _package_dir(package_path)
|
||||
raw = _read_yaml(root / "plugin.yaml")
|
||||
if "id" in raw and "plugin_id" not in raw:
|
||||
raw["plugin_id"] = raw.pop("id")
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("plugin", exc) from exc
|
||||
_validate_id("plugin", manifest.plugin_id)
|
||||
if manifest.plugin_id in self._records:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_ALREADY_INSTALLED",
|
||||
f"Plugin is already installed: {manifest.plugin_id}",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
specs = self._load_tools(root)
|
||||
declared = set(manifest.contributes.tools)
|
||||
actual = {spec.name for spec in specs}
|
||||
if declared != actual:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_CONTRIBUTION_INVALID",
|
||||
"plugin.yaml tool contributions must exactly match tools.yaml",
|
||||
details={"declared": sorted(declared), "actual": sorted(actual)},
|
||||
)
|
||||
for spec in specs:
|
||||
_validate_id("tool", spec.name)
|
||||
if spec.permission and spec.permission not in manifest.permissions:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_PERMISSION_UNDECLARED",
|
||||
f"Tool permission is not declared by Plugin: {spec.permission}",
|
||||
details={"tool": spec.name, "permission": spec.permission},
|
||||
)
|
||||
|
||||
record = _PluginRecord(
|
||||
plugin=Plugin(manifest=manifest, status=PluginStatus.installed),
|
||||
tools=specs,
|
||||
package_path=root,
|
||||
registered_tools=[],
|
||||
)
|
||||
self._records[manifest.plugin_id] = record
|
||||
return record.plugin.model_copy(deep=True)
|
||||
|
||||
def list(self) -> list[Plugin]:
|
||||
return [record.plugin.model_copy(deep=True) for record in self._records.values()]
|
||||
|
||||
def get(self, plugin_id: str) -> Plugin:
|
||||
return self._record(plugin_id).plugin.model_copy(deep=True)
|
||||
|
||||
def enable(self, plugin_id: str) -> Plugin:
|
||||
record = self._record(plugin_id)
|
||||
if record.plugin.enabled:
|
||||
return record.plugin.model_copy(deep=True)
|
||||
if record.plugin.manifest.backend.type == "mcp":
|
||||
record.plugin.status = PluginStatus.dependency_missing
|
||||
raise ExtensionError(
|
||||
"PLUGIN_HOST_UNAVAILABLE",
|
||||
"MCP Plugin Host is reserved for the second development phase.",
|
||||
status_code=501,
|
||||
details={"plugin_id": plugin_id, "backend": "mcp"},
|
||||
)
|
||||
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)]
|
||||
if conflicts:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_TOOL_CONFLICT",
|
||||
f"Plugin tools are already registered: {', '.join(conflicts)}",
|
||||
status_code=409,
|
||||
details={"plugin_id": plugin_id, "tools": conflicts},
|
||||
)
|
||||
record.plugin.status = PluginStatus.starting
|
||||
try:
|
||||
for spec in record.tools:
|
||||
arguments_model = _arguments_model(spec)
|
||||
|
||||
async def executor(
|
||||
arguments: BaseModel,
|
||||
context: ToolExecutionContext,
|
||||
_handler: str = spec.handler,
|
||||
) -> Any:
|
||||
return await self.host.execute(_handler, arguments, context)
|
||||
|
||||
self.registry.register(
|
||||
ToolDefinition(
|
||||
name=spec.name,
|
||||
description=spec.description,
|
||||
parameters=spec.parameters,
|
||||
permission=spec.permission,
|
||||
source="plugin",
|
||||
),
|
||||
arguments_model,
|
||||
executor,
|
||||
)
|
||||
record.registered_tools.append(spec.name)
|
||||
except Exception as exc:
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
record.plugin.status = PluginStatus.error
|
||||
record.plugin.error_message = str(exc)
|
||||
raise
|
||||
record.plugin.enabled = True
|
||||
record.plugin.status = PluginStatus.ready
|
||||
record.plugin.error_message = None
|
||||
return record.plugin.model_copy(deep=True)
|
||||
|
||||
def disable(self, plugin_id: str) -> Plugin:
|
||||
record = self._record(plugin_id)
|
||||
for name in record.registered_tools:
|
||||
self.registry.unregister(name)
|
||||
record.registered_tools.clear()
|
||||
record.plugin.enabled = False
|
||||
record.plugin.status = PluginStatus.disabled
|
||||
return record.plugin.model_copy(deep=True)
|
||||
|
||||
def uninstall(self, plugin_id: str, dependent_skills: list[str] | None = None) -> None:
|
||||
record = self._record(plugin_id)
|
||||
if dependent_skills:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_IN_USE",
|
||||
"Enabled Skills depend on this Plugin.",
|
||||
status_code=409,
|
||||
details={"plugin_id": plugin_id, "skills": dependent_skills},
|
||||
)
|
||||
if record.plugin.enabled:
|
||||
self.disable(plugin_id)
|
||||
del self._records[plugin_id]
|
||||
|
||||
def _record(self, plugin_id: str) -> _PluginRecord:
|
||||
try:
|
||||
return self._records[plugin_id]
|
||||
except KeyError as exc:
|
||||
raise ExtensionError(
|
||||
"PLUGIN_NOT_FOUND", f"Plugin is not installed: {plugin_id}", status_code=404
|
||||
) from exc
|
||||
|
||||
@staticmethod
|
||||
def _load_tools(root: Path) -> list[DeclarativeToolSpec]:
|
||||
path = root / "tools.yaml"
|
||||
if not path.exists():
|
||||
return []
|
||||
raw = _read_yaml(path)
|
||||
try:
|
||||
return [DeclarativeToolSpec.model_validate(item) for item in raw.get("tools", [])]
|
||||
except ValidationError as exc:
|
||||
raise _manifest_error("plugin tool", exc) from exc
|
||||
|
||||
|
||||
def _package_dir(package_path: str | Path) -> Path:
|
||||
root = Path(package_path).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
raise ExtensionError(
|
||||
"EXTENSION_PACKAGE_NOT_FOUND",
|
||||
f"Extension package directory does not exist: {root}",
|
||||
status_code=404,
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
raise ExtensionError(
|
||||
"EXTENSION_MANIFEST_NOT_FOUND", f"Manifest does not exist: {path}", status_code=404
|
||||
)
|
||||
try:
|
||||
value = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise ExtensionError("EXTENSION_MANIFEST_INVALID", f"Cannot read manifest: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ExtensionError("EXTENSION_MANIFEST_INVALID", "Manifest root must be an object.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_id(kind: str, value: str) -> None:
|
||||
if not _EXTENSION_ID.fullmatch(value):
|
||||
raise ExtensionError(
|
||||
"EXTENSION_ID_INVALID",
|
||||
f"Invalid {kind} id: {value}",
|
||||
details={"kind": kind, "id": value},
|
||||
)
|
||||
|
||||
|
||||
def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
|
||||
return ExtensionError(
|
||||
"EXTENSION_MANIFEST_INVALID",
|
||||
f"Invalid {kind} manifest.",
|
||||
details={"errors": exc.errors(include_url=False)},
|
||||
)
|
||||
|
||||
|
||||
def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
|
||||
schema = spec.parameters or {"type": "object", "properties": {}}
|
||||
if schema.get("type", "object") != "object":
|
||||
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.")
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
fields: dict[str, tuple[Any, Any]] = {}
|
||||
types = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"integer": int,
|
||||
"boolean": bool,
|
||||
"array": list[Any],
|
||||
"object": dict[str, Any],
|
||||
}
|
||||
for name, field_schema in properties.items():
|
||||
annotation = types.get(field_schema.get("type"), Any)
|
||||
fields[name] = (annotation, ... if name in required else None)
|
||||
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
|
||||
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
|
||||
Reference in New Issue
Block a user