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