Files
NotesAgentic/backend/app/extensions/runtime.py

539 lines
20 KiB
Python

from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
import yaml
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, create_model
from app.agent.tools import ToolExecutionContext, ToolRegistry
from app.agent.permissions import KNOWN_PERMISSIONS
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:
# TODO(extension): 将安装记录持久化,应用重启后从可信包目录恢复状态。
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)
_validate_permissions("skill", manifest.permissions)
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:
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码。
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)
_validate_permissions("plugin", manifest.permissions)
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)
_validate_tool_schema(spec)
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.permission_required
if manifest.permissions
else 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":
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
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"},
)
missing_grants = sorted(
set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions)
)
if missing_grants:
record.plugin.status = PluginStatus.permission_required
raise ExtensionError(
"PLUGIN_PERMISSION_REQUIRED",
"Plugin permissions must be granted before it can be enabled.",
status_code=409,
details={"plugin_id": plugin_id, "permissions": missing_grants},
)
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 set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
record = self._record(plugin_id)
requested = set(permissions)
declared = set(record.plugin.manifest.permissions)
undeclared = sorted(requested - declared)
if undeclared:
raise ExtensionError(
"PLUGIN_PERMISSION_UNDECLARED",
"Cannot grant permissions that are not declared by the Plugin.",
details={"plugin_id": plugin_id, "permissions": undeclared},
)
record.plugin.granted_permissions = sorted(requested)
missing = declared - requested
if missing and record.plugin.enabled:
self.disable(plugin_id)
if missing:
record.plugin.status = PluginStatus.permission_required
elif not record.plugin.enabled:
record.plugin.status = PluginStatus.installed
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 _validate_permissions(kind: str, permissions: list[str]) -> None:
unknown = sorted(set(permissions) - KNOWN_PERMISSIONS)
if unknown:
raise ExtensionError(
"EXTENSION_PERMISSION_INVALID",
f"Invalid {kind} permissions: {', '.join(unknown)}",
details={"kind": kind, "permissions": unknown},
)
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)
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
schema = spec.parameters or {"type": "object", "properties": {}}
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
f"Invalid JSON Schema for tool {spec.name}: {exc.message}",
details={"tool": spec.name},
) from exc
if schema.get("type", "object") != "object" or not isinstance(
schema.get("properties", {}), dict
):
raise ExtensionError(
"PLUGIN_TOOL_SCHEMA_INVALID",
"Tool parameters must be an object schema with object properties.",
details={"tool": spec.name},
)