refactor(agent): clarify plugin module boundary

This commit is contained in:
Xubin Ren
2026-08-10 16:42:09 +09:00
parent 70bd454d32
commit 1a04750ada
10 changed files with 102 additions and 125 deletions
+1 -1
View File
@@ -480,7 +480,7 @@ class AgentLoop:
config,
provider_snapshot_loader,
)
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls(
bus=bus,
@@ -2,7 +2,6 @@
from __future__ import annotations
import base64
import json
import os
import re
@@ -44,12 +43,7 @@ _MCP_SERVER_FIELDS = {
}
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
_SETUP_TIMEOUT_SECONDS = 600
_LOGO_MIME_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
_LOGO_SUFFIXES = {".jpeg", ".jpg", ".png", ".webp"}
_MAX_LOGO_BYTES = 256 * 1024
@@ -79,6 +73,16 @@ class AgentPlugin:
install_command: tuple[str, ...]
@dataclass(frozen=True)
class AgentPluginState:
"""Runtime state for one discovered Agent Plugin."""
plugin: AgentPlugin
mcp_servers: tuple[str, ...]
enabled: bool
setup_required: bool
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return valid packages from ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
@@ -205,37 +209,23 @@ def agent_plugin_mcp_servers(
return servers
def agent_plugins_payload(workspace: Path) -> dict[str, Any]:
"""Return installed Agent Plugins for the WebUI Apps surface."""
plugins: list[dict[str, Any]] = []
enabled_count = 0
def discover_agent_plugin_states(workspace: Path) -> list[AgentPluginState]:
"""Return component and lifecycle state for discovered plugins."""
states: list[AgentPluginState] = []
for plugin in discover_agent_plugins(workspace):
mcp_servers = sorted(_plugin_mcp_servers(workspace, plugin))
if not mcp_servers and not plugin.install_command:
continue
enabled = _enabled(workspace, plugin.name)
enabled_count += int(enabled)
plugins.append(
{
"name": plugin.name,
"display_name": plugin.display_name,
"version": plugin.version,
"description": plugin.description,
"category": plugin.category,
"repository": plugin.repository,
"accent_color": plugin.accent_color,
"logo_url": _plugin_logo_data_url(plugin.logo),
"permissions": list(plugin.permissions),
"mcp_servers": mcp_servers,
"enabled": enabled,
"setup_required": bool(plugin.install_command)
states.append(
AgentPluginState(
plugin=plugin,
mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))),
enabled=_enabled(workspace, plugin.name),
setup_required=bool(plugin.install_command)
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
}
)
)
return {"plugins": plugins, "enabled_count": enabled_count}
return states
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[str, Any]:
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> AgentPlugin:
"""Enable or disable one installed plugin."""
plugin = next((item for item in discover_agent_plugins(workspace) if item.name == name), None)
if plugin is None:
@@ -251,12 +241,7 @@ def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[
_write_state(data / "enabled", "1")
else:
(data / "enabled").unlink(missing_ok=True)
payload = agent_plugins_payload(workspace)
payload["last_action"] = {
"ok": True,
"message": f"{plugin.display_name} {'enabled' if enabled else 'disabled'}.",
}
return payload
return plugin
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
@@ -302,7 +287,7 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
logo = _contained_file(plugin_root / value[2:], plugin_root)
if logo is None or logo.suffix.lower() not in _LOGO_MIME_TYPES:
if logo is None or logo.suffix.lower() not in _LOGO_SUFFIXES:
logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root)
return None
try:
@@ -314,26 +299,6 @@ def _plugin_logo(value: object, plugin_root: Path) -> Path | None:
return logo
def _plugin_logo_data_url(path: Path | None) -> str | None:
if path is None:
return None
try:
data = path.read_bytes()
except OSError:
return None
suffix = path.suffix.lower()
valid = (
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"
)
if not valid:
logger.warning("Ignoring malformed Agent Plugin logo '{}'", path)
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:{_LOGO_MIME_TYPES[suffix]};base64,{encoded}"
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
"""Validate nanobot's optional, shell-free setup command extension."""
if not isinstance(value, list):
+2 -2
View File
@@ -60,7 +60,7 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
from nanobot.agent.agent_plugins import enabled_agent_plugin_skills
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")
@@ -102,7 +102,7 @@ class SkillsLoader:
workspace_path = self.workspace_skills / name / "SKILL.md"
if workspace_path.exists():
return workspace_path.read_text(encoding="utf-8")
from nanobot.agent.agent_plugins import enabled_agent_plugin_skills
from nanobot.agent.plugins import enabled_agent_plugin_skills
for plugin_skill in enabled_agent_plugin_skills(self.workspace):
if plugin_skill.name == name and plugin_skill.path.is_file():
+1 -1
View File
@@ -1286,7 +1286,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True,
}
try:
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
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())
+1 -1
View File
@@ -1149,7 +1149,7 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
shutil.rmtree(legacy_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
from nanobot.agent.agent_plugins import set_agent_plugin_enabled
from nanobot.agent.plugins import set_agent_plugin_enabled
installed = self._load_installed()
entry = self._installed_entry(app)
+59 -33
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import base64
import json
import os
import re
@@ -16,7 +17,11 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal, Mapping, cast
from nanobot.agent.agent_plugins import agent_plugins_payload, set_agent_plugin_enabled
from nanobot.agent.plugins import (
AgentPluginState,
discover_agent_plugin_states,
set_agent_plugin_enabled,
)
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
@@ -49,6 +54,12 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
_PLUGIN_LOGO_MIME_TYPES = {
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
McpReload = Callable[[], Awaitable[dict[str, Any]]]
@@ -838,38 +849,45 @@ def _custom_payload(
}
def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
enabled = bool(plugin.get("enabled"))
permissions = plugin.get("permissions")
mcp_servers = plugin.get("mcp_servers")
permission_names = (
[str(item) for item in cast(list[object], permissions)]
if isinstance(permissions, list)
else []
)
server_names = (
[str(item) for item in cast(list[object], mcp_servers)]
if isinstance(mcp_servers, list)
else []
def _plugin_logo_data_url(path: Path | None) -> str | None:
if path is None:
return None
try:
data = path.read_bytes()
except OSError:
return None
suffix = path.suffix.lower()
valid = (
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"
)
if not valid:
return None
encoded = base64.b64encode(data).decode("ascii")
return f"data:{_PLUGIN_LOGO_MIME_TYPES[suffix]};base64,{encoded}"
def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
plugin = state.plugin
return {
"name": f"plugin-{plugin['name']}",
"display_name": str(plugin.get("display_name") or plugin["name"]),
"category": str(plugin.get("category") or "plugin"),
"description": str(plugin.get("description") or "Agent Plugin"),
"docs_url": str(plugin.get("repository") or ""),
"name": f"plugin-{plugin.name}",
"display_name": plugin.display_name,
"category": plugin.category,
"description": plugin.description or "Agent Plugin",
"docs_url": plugin.repository,
"transport": "stdio",
"requires": ", ".join(permission_names),
"requires": ", ".join(plugin.permissions),
"note": "",
"install_supported": True,
"installed": True,
"configured": enabled,
"available": enabled,
"status": "configured" if enabled else "not_installed",
"logo_url": plugin.get("logo_url"),
"brand_color": plugin.get("accent_color"),
"configured": state.enabled,
"available": state.enabled,
"status": "configured" if state.enabled else "not_installed",
"logo_url": _plugin_logo_data_url(plugin.logo),
"brand_color": plugin.accent_color,
"required_fields": [],
"connection_summary": ", ".join(server_names),
"connection_summary": ", ".join(state.mcp_servers),
"enabled_tools": ["*"],
"tool_names": [],
"source": "agent-plugin",
@@ -893,12 +911,16 @@ def mcp_presets_payload(
for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known
]
plugin_state = agent_plugins_payload(config.workspace_path)
plugin_states = [
state
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
]
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
plugin_rows = [
row
for plugin in plugin_state["plugins"]
if (row := _agent_plugin_payload(plugin))["name"] not in existing_names
for state in plugin_states
if (row := _agent_plugin_payload(state))["name"] not in existing_names
]
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows],
@@ -1395,19 +1417,23 @@ async def mcp_presets_settings_action(
config = load_config()
plugin_name = name.removeprefix("plugin-")
installed = {
str(plugin["name"])
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
state.plugin.name
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
}
if name not in config.tools.mcp_servers and plugin_name in installed:
if action not in {"enable", "remove"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
state = await asyncio.to_thread(
plugin = await asyncio.to_thread(
set_agent_plugin_enabled,
config.workspace_path,
plugin_name,
action == "enable",
)
payload = mcp_presets_payload(last_action=state.get("last_action"))
verb = "enabled" if action == "enable" else "disabled"
payload = mcp_presets_payload(
last_action={"ok": True, "message": f"{plugin.display_name} {verb}."}
)
if reload_mcp is not None:
payload = attach_mcp_hot_reload_result(payload, await reload_mcp())
return payload
+4 -3
View File
@@ -18,7 +18,7 @@ from urllib.parse import unquote
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.agent_plugins import agent_plugins_payload
from nanobot.agent.plugins import discover_agent_plugin_states
from nanobot.agent.tools.image_generation import request_image_generation_reload
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
@@ -1157,8 +1157,9 @@ class WebUISettingsRouter:
if action == "enable" and name.startswith("plugin-"):
config = load_config()
plugin_names = {
f"plugin-{plugin['name']}"
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
f"plugin-{state.plugin.name}"
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
}
if (
name not in config.tools.mcp_servers
+6 -21
View File
@@ -9,13 +9,13 @@ from typing import Any, cast
import pytest
from nanobot.agent import agent_plugins
from nanobot.agent.agent_plugins import (
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import (
AGENT_PLUGIN_MCP_SCHEMA,
AGENT_PLUGIN_SCHEMA,
agent_plugin_mcp_servers,
agent_plugins_payload,
discover_agent_plugin_skills,
discover_agent_plugin_states,
set_agent_plugin_enabled,
)
from nanobot.agent.skills import SkillsLoader
@@ -142,7 +142,7 @@ def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path:
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
def test_agent_plugin_payload_embeds_contained_raster_logo(tmp_path: Path) -> None:
def test_agent_plugin_discovers_contained_raster_logo(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
@@ -155,22 +155,7 @@ def test_agent_plugin_payload_embeds_contained_raster_logo(tmp_path: Path) -> No
assets = plugin / "assets"
assets.mkdir()
(assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
(plugin / "mcp.json").write_text(
json.dumps(
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {"demo": {"type": "stdio", "command": "./bin/server"}},
}
),
encoding="utf-8",
)
logo_url = agent_plugins_payload(tmp_path)["plugins"][0]["logo_url"]
assert logo_url == "data:image/png;base64,iVBORw0KGgpsb2dv"
assert agent_plugins.discover_agent_plugins(tmp_path)[0].logo == assets / "icon.png"
def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None:
@@ -351,7 +336,7 @@ def test_plugin_setup_command_runs_once_per_version(
assert calls[0][0] == (str(executable),)
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
assert agent_plugins_payload(tmp_path)["plugins"][0]["setup_required"] is False
assert discover_agent_plugin_states(tmp_path)[0].setup_required is False
def test_concurrent_plugin_enable_runs_setup_once(
+2 -2
View File
@@ -9,8 +9,8 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import agent_plugins
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import discover_agent_plugin_skills
from nanobot.agent.skills import SkillsLoader
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from nanobot.agent.agent_plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import (
McpPresetError,