mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
refactor(agent): clarify plugin module boundary
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
@@ -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():
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user