feat(plugins): integrate portable Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-11 20:16:24 +09:00
parent 57d81bc1cd
commit d5e0df6963
23 changed files with 1111 additions and 110 deletions
+3 -1
View File
@@ -485,6 +485,8 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
provider=provider,
@@ -499,7 +501,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers),
channels_config=config.channels,
timezone=defaults.timezone,
unified_session=defaults.unified_session,
+361
View File
@@ -0,0 +1,361 @@
"""Load and activate locally installed Agent Plugin packages."""
from __future__ import annotations
import base64
import json
import re
from dataclasses import dataclass, replace
from hashlib import sha256
from pathlib import Path
from typing import cast
from loguru import logger
from pydantic import ValidationError
from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata
from nanobot.config.loader import get_config_path
from nanobot.config.schema import MCPServerConfig
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"}
_MAX_LOGO_BYTES = 256 * 1024
@dataclass(frozen=True)
class AgentPlugin:
"""A validated, locally installed Agent Plugins v1 package."""
name: str
root: Path
description: str
repository: str
display_name: str
category: str
accent_color: str | None
logo: str | None
permissions: tuple[str, ...]
mcp_servers: tuple[str, ...] = ()
enabled: bool = False
def _installed_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return installed packages found under ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
root = _contained(workspace / "plugins", workspace, directory=True)
if root is None:
return []
plugins: dict[str, AgentPlugin | None] = {}
for candidate in _children(root, "Agent Plugins directory"):
plugin_root = _contained(candidate, root, directory=True)
if plugin_root is None:
continue
plugin = _load_manifest(plugin_root)
if plugin is not None:
if plugin.name in plugins:
logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name)
plugins[plugin.name] = None
else:
plugins[plugin.name] = plugin
return [plugin for plugin in plugins.values() if plugin is not None]
def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]:
"""Return skills from plugins the user has explicitly enabled."""
return [
skill
for plugin in _installed_plugins(workspace)
if _enabled(workspace, plugin)
for skill in _discover_plugin_skills(plugin.name, plugin.root)
]
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
payload = _read_object(plugin_root / "plugin.json", plugin_root)
if payload is None:
return None
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
return None
name = payload.get("name")
if (
not isinstance(name, str)
or len(name) > 64
or _PLUGIN_NAME.fullmatch(name) is None
):
logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root)
return None
extension = payload.get("extensions")
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
nanobot_value = extension_payload.get("dev.nanobot")
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
return AgentPlugin(
name=name,
root=plugin_root,
description=_string(payload.get("description")),
repository=_string(payload.get("repository")),
display_name=_string(nanobot.get("displayName")) or name,
category=_string(nanobot.get("category")) or "Plugin",
accent_color=_accent_color(nanobot.get("accentColor")),
logo=_plugin_logo(nanobot.get("logo"), plugin_root),
permissions=_string_tuple(nanobot.get("permissions")),
)
def agent_plugin_mcp_servers(
workspace: Path,
configured: dict[str, MCPServerConfig] | None = None,
) -> dict[str, MCPServerConfig]:
"""Merge explicitly enabled plugin MCP servers with user configuration.
User configuration wins on the unlikely event of a namespaced collision.
"""
servers: dict[str, MCPServerConfig] = {}
for plugin in _installed_plugins(workspace):
if not _enabled(workspace, plugin):
continue
plugin_servers = _plugin_mcp_servers(workspace, plugin)
for name, server in plugin_servers.items():
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
servers[host_name] = server
configured = configured or {}
if collisions := servers.keys() & configured.keys():
logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions)))
return servers | configured
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return component and lifecycle state for discovered plugins."""
return [
replace(
plugin,
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
enabled=_enabled(workspace, plugin),
)
for plugin in _installed_plugins(workspace)
]
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
"""Enable or disable one installed plugin."""
plugin = next((item for item in _installed_plugins(workspace) if item.name == name), None)
if plugin is None:
raise ValueError(f"unknown Agent Plugin '{name}'")
data = _plugin_data_dir(workspace, plugin.name, create=True)
marker = data / "enabled"
if enabled:
marker.write_text(str(plugin.root), encoding="utf-8")
marker.chmod(0o600)
else:
marker.unlink(missing_ok=True)
def _string(value: object) -> str:
return value.strip() if isinstance(value, str) else ""
def _string_tuple(value: object) -> tuple[str, ...]:
items = cast(list[object], value) if isinstance(value, list) else []
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
def _accent_color(value: object) -> str | None:
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
def _plugin_logo(value: object, plugin_root: Path) -> str | None:
"""Resolve nanobot's optional packaged logo extension."""
if value is None:
return None
if not isinstance(value, str) or not value.startswith("./"):
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
logo = _contained(plugin_root / value[2:], plugin_root)
try:
data = logo.read_bytes() if logo is not None else b""
suffix = logo.suffix.lower() if logo is not None else ""
if len(data) <= _MAX_LOGO_BYTES and (
suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n")
or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff")
or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP"
):
mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:]
return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}"
except OSError:
pass
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
payload = _read_object(plugin.root / "mcp.json", plugin.root)
if payload is None:
return {}
raw_servers = payload.get("mcpServers")
if (
payload.keys() != {"$schema", "mcpServers"}
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
or not isinstance(raw_servers, dict)
):
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
return {}
data = _plugin_data_dir(workspace, plugin.name, create=True)
servers: dict[str, MCPServerConfig] = {}
for name, raw in cast(dict[str, object], raw_servers).items():
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
continue
server = _plugin_mcp_server(raw, plugin.root, data)
if server is None:
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
continue
servers[name] = server
return servers
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
if not isinstance(raw, dict):
return None
payload = cast(dict[str, object], raw)
if payload.keys() - _MCP_SERVER_FIELDS:
return None
try:
server = MCPServerConfig.model_validate(payload)
except ValidationError:
return None
command = _stdio_command(server.command, root)
cwd = _stdio_cwd(payload.get("cwd"), root, data)
if server.type != "stdio" or command is None or cwd is None:
return None
if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys():
return None
return server.model_copy(
update={
"command": command,
"args": [_expand(item, root, data) for item in server.args],
"env": {
**{key: _expand(value, root, data) for key, value in server.env.items()},
"PLUGIN_ROOT": str(root),
"PLUGIN_DATA": str(data),
},
"cwd": str(cwd),
}
)
def _stdio_command(value: object, root: Path) -> str | None:
if not isinstance(value, str) or not value:
return None
if value.startswith("./"):
executable = _contained(root / value[2:], root)
return str(executable) if executable is not None else None
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
return None
return value
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
if value is None:
return root
if not isinstance(value, str):
return None
if value.startswith("./"):
return _contained(root / value[2:], root, directory=True)
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
if value == placeholder or value.startswith(f"{placeholder}/"):
relative = value[len(placeholder):].lstrip("/")
candidate = (base / relative).resolve()
if not candidate.is_relative_to(base):
return None
if base == data:
candidate.mkdir(parents=True, exist_ok=True)
candidate.chmod(0o700)
return candidate if candidate.is_dir() else None
return None
def _expand(value: str, root: Path, data: Path) -> str:
return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data))
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
current = get_config_path().expanduser().resolve().parent
for segment in ("plugin-data", workspace_id, name):
path = current / segment
if create:
path.mkdir(parents=True, exist_ok=True)
try:
resolved = path.resolve(strict=create)
except OSError as exc:
raise RuntimeError("Agent Plugin data directory is unavailable") from exc
if not resolved.is_relative_to(current):
raise RuntimeError("Agent Plugin data directory escapes its parent")
if create:
resolved.chmod(0o700)
current = resolved
return current
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
try:
return marker.is_file() and marker.read_text(encoding="utf-8") == str(plugin.root)
except OSError:
return False
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
if skills_root is None:
return []
skills: list[tuple[str, Path]] = []
for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"):
skill_root = _contained(candidate, skills_root, directory=True)
if skill_root is None:
continue
skill_file = _contained(skill_root / "SKILL.md", plugin_root)
if skill_file is None:
continue
try:
metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8"))
except (OSError, UnicodeError):
metadata = None
if metadata is None or not valid_skill_metadata(metadata, candidate.name):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name)
continue
skills.append((candidate.name, skill_file))
return skills
def _children(root: Path, label: str) -> list[Path]:
try:
return sorted(root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect {}: {}", label, exc)
return []
def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
expected_kind = resolved.is_dir() if directory else resolved.is_file()
return resolved if expected_kind and resolved.is_relative_to(root) else None
def _read_object(path: Path, root: Path) -> dict[str, object] | None:
contained = _contained(path, root)
if contained is None:
return None
try:
value = cast(object, json.loads(contained.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc)
return None
return cast(dict[str, object], value) if isinstance(value, dict) else None
+66 -30
View File
@@ -17,9 +17,35 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL,
)
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
def parse_skill_metadata(content: str) -> dict[str, object] | None:
"""Parse a skill document's YAML frontmatter."""
if not (match := _STRIP_SKILL_FRONTMATTER.match(content)):
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
return {str(key): value for key, value in cast(dict[object, object], parsed).items()}
def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool:
"""Return whether metadata satisfies the Agent Skills identity contract."""
description = metadata.get("description")
return (
metadata.get("name") == name
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
class SkillsLoader:
"""
Loader for agent skills.
@@ -34,6 +60,15 @@ class SkillsLoader:
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set()
def _skill_aliases(self) -> dict[str, str]:
"""Return compatibility aliases owned by installed CLI Apps."""
from nanobot.apps.cli import CliAppManager
try:
return CliAppManager(workspace=self.workspace).installed_skill_aliases()
except OSError:
return {}
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists():
return []
@@ -60,15 +95,33 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
from nanobot.agent.plugins import enabled_agent_plugin_skills
plugin_skills = enabled_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
workspace_names = {entry["name"] for entry in skills}
seen_names = {entry["name"] for entry in skills}
for name, path in plugin_skills:
if name in seen_names:
continue
skills.append(
{
"name": name,
"path": str(path),
"source": "plugin",
}
)
seen_names.add(name)
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
)
if self.disabled_skills:
skills = [s for s in skills if s["name"] not in self.disabled_skills]
disabled = set(self.disabled_skills)
for legacy, canonical in self._skill_aliases().items():
if legacy in disabled or canonical in disabled:
disabled.update((legacy, canonical))
skills = [s for s in skills if s["name"] not in disabled]
if filter_unavailable:
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
@@ -84,14 +137,11 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
roots = [self.workspace_skills]
if self.builtin_skills:
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
return None
skills = self.list_skills(filter_unavailable=False)
available = {skill["name"] for skill in skills}
resolved = name if name in available else self._skill_aliases().get(name, name)
entry = next((skill for skill in skills if skill["name"] == resolved), None)
return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
def load_skills_for_context(self, skill_names: list[str]) -> str:
"""
@@ -118,9 +168,11 @@ class SkillsLoader:
entry["name"]
for entry in self.list_skills(filter_unavailable=True)
}
aliases = self._skill_aliases()
invoked: list[str] = []
for match in _SKILL_REFERENCE.finditer(text):
name = match.group(1)
requested = match.group(1)
name = requested if requested in available else aliases.get(requested, requested)
if name in available and name not in invoked:
invoked.append(name)
return invoked
@@ -145,6 +197,7 @@ class SkillsLoader:
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills),
)
for label, source, root in groups:
@@ -278,21 +331,4 @@ class SkillsLoader:
Returns:
Metadata dict or None.
"""
content = self.load_skill(name)
if not content or not content.startswith("---"):
return None
match = _STRIP_SKILL_FRONTMATTER.match(content)
if not match:
return None
try:
parsed = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return None
if not isinstance(parsed, dict):
return None
# yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {}
for key, value in cast(dict[object, object], parsed).items():
metadata[str(key)] = value
return metadata
return parse_skill_metadata(self.load_skill(name) or "")
+5 -1
View File
@@ -1340,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
try:
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config())
next_servers = dict(config.tools.mcp_servers)
next_servers = agent_plugin_mcp_servers(
config.workspace_path,
config.tools.mcp_servers,
)
except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc)
return {