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)