460 lines
17 KiB
Python
460 lines
17 KiB
Python
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)
|