diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 02a8ca55d..23c555cf4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -485,7 +485,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, diff --git a/nanobot/agent/agent_plugins.py b/nanobot/agent/plugins.py similarity index 90% rename from nanobot/agent/agent_plugins.py rename to nanobot/agent/plugins.py index 7fe81ce68..2efed1616 100644 --- a/nanobot/agent/agent_plugins.py +++ b/nanobot/agent/plugins.py @@ -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 ``/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): diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index c9836d594..deb0ac90a 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -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(): diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 30f916849..0106d77f1 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -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()) diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 137d76fab..b3160bde6 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -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) diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index e37a631c3..097153643 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -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 TYPE_CHECKING, 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 @@ -52,6 +57,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]]] @@ -841,38 +852,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", @@ -897,12 +915,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], @@ -1440,20 +1462,22 @@ async def mcp_presets_settings_action( plugin_config = load_config(config_path) if config_path is not None else load_config() plugin_name = name.removeprefix("plugin-") installed = { - str(plugin["name"]) - for plugin in agent_plugins_payload(plugin_config.workspace_path)["plugins"] + state.plugin.name + for state in discover_agent_plugin_states(plugin_config.workspace_path) + if state.mcp_servers or state.plugin.install_command } if name not in plugin_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, plugin_config.workspace_path, plugin_name, action == "enable", ) + verb = "enabled" if action == "enable" else "disabled" payload = mcp_presets_payload( - last_action=state.get("last_action"), + last_action={"ok": True, "message": f"{plugin.display_name} {verb}."}, config_path=config_path, ) if reload_mcp is not None: diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 6b2a7b2d5..30f57439e 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -17,7 +17,7 @@ from typing import Any, cast 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 @@ -1221,8 +1221,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 diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py index 0bf3fb11a..e6e05d874 100644 --- a/tests/agent/test_agent_plugins.py +++ b/tests/agent/test_agent_plugins.py @@ -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( diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 3377b71eb..2e16ec72f 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -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 diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 1810d667c..891a75d0f 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -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,