From 96518974beb8835f64ee9cca62f8bb9cf98c4c22 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 15:09:11 +0800 Subject: [PATCH] core/tools: allowlisted entry-point plugin loader --- src/plyngent/cli/state.py | 15 ++++- src/plyngent/config/models.py | 6 ++ src/plyngent/tools/__init__.py | 1 + src/plyngent/tools/plugins.py | 86 +++++++++++++++++++++++++ tests/test_config/test_agent_section.py | 2 + tests/test_tools/test_plugins.py | 63 ++++++++++++++++++ 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 src/plyngent/tools/plugins.py create mode 100644 tests/test_tools/test_plugins.py diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index da627b9..9169b45 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -21,7 +21,6 @@ from plyngent.runtime import create_client from plyngent.tools import ( InstanceState, SessionState, - default_tool_definitions, set_todo_stack, set_workspace_root, ) @@ -178,9 +177,21 @@ class ReplState: if not self.tools_enabled: return None from plyngent.cli.limits import prompt_confirm_tool_async + from plyngent.tools.catalog import register_builtin_tools from plyngent.tools.danger import classify_danger + from plyngent.tools.plugins import load_plugin_tools - tools = default_tool_definitions(surface="local") + agent_cfg = self.config.agent_config + catalog = register_builtin_tools() + _ = load_plugin_tools( + agent_cfg.tool_plugins, + disable=agent_cfg.tool_plugins_disable, + ) + # Local surface: builtins + allowlisted plugins (import registers into catalog). + tools = catalog.select(surface="local") + disable = {name.strip() for name in agent_cfg.tool_plugins_disable if name.strip()} + if disable: + tools = [tool for tool in tools if tool.name not in disable] yolo = self.effective_yolo() != "off" # Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode. return ToolRegistry( diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 28b944a..ba6acd0 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -87,6 +87,12 @@ class AgentConfig(Struct, omit_defaults=True): path_denylist: list[str] = field(default_factory=list) max_context_tokens: int = 200_000 + # Third-party tool plugins (entry-point group ``plyngent.tools``). + # Default empty = load none. Use ``["*"]`` to load all discovered plugins. + tool_plugins: list[str] = field(default_factory=list) + # Entry-point names never loaded even when listed / ``*``. + tool_plugins_disable: list[str] = field(default_factory=list) + # How to inject todo stack nags into model context (see agent/todo_nag.py). # developer | user | synthetic_tool | none (legacy "system" → developer) todo_nag_strategy: str = "developer" diff --git a/src/plyngent/tools/__init__.py b/src/plyngent/tools/__init__.py index b4f0e12..43577c8 100644 --- a/src/plyngent/tools/__init__.py +++ b/src/plyngent/tools/__init__.py @@ -28,6 +28,7 @@ from .file import move_path as move_path from .file import read_file as read_file from .file import tree as tree from .file import write_file as write_file +from .plugins import load_plugin_tools as load_plugin_tools from .process import PROCESS_TOOLS as PROCESS_TOOLS from .process import ask_into_pty as ask_into_pty from .process import close_pty as close_pty diff --git a/src/plyngent/tools/plugins.py b/src/plyngent/tools/plugins.py new file mode 100644 index 0000000..277b9bb --- /dev/null +++ b/src/plyngent/tools/plugins.py @@ -0,0 +1,86 @@ +"""Load third-party tools via package entry points (allowlisted).""" + +from __future__ import annotations + +import importlib.metadata +from typing import TYPE_CHECKING + +from plyngent.tools.catalog import ToolSource, get_catalog, registration_source + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + +ENTRY_POINT_GROUP = "plyngent.tools" + + +def _iter_entry_points() -> list[importlib.metadata.EntryPoint]: + try: + selected = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP) + except TypeError: + # Older importlib.metadata API (unlikely on 3.14, kept for clarity). + selected = importlib.metadata.entry_points().select(group=ENTRY_POINT_GROUP) + return list(selected) + + +def resolve_plugin_allowlist(plugins: Sequence[str] | None) -> set[str] | None: + """Return the set of plugin ids to load, or ``None`` meaning all. + + - ``None`` / empty → load **no** plugins (default safe). + - ``["*"]`` → load every discovered entry point. + - otherwise → only listed entry-point names. + """ + if not plugins: + return set() + if any(item.strip() == "*" for item in plugins): + return None + return {item.strip() for item in plugins if item.strip()} + + +def load_plugin_tools( + plugins: Sequence[str] | None = None, + *, + disable: Iterable[str] | None = None, +) -> list[str]: + """Import allowlisted ``plyngent.tools`` entry points under plugin sources. + + Each entry point's ``load()`` may call ``@tool``; registrations use + ``ToolSource(kind="plugin", plugin_id=ep.name)``. Builtin name collisions + still fail at catalog.register. + + Returns the list of plugin ids that were loaded successfully. + """ + allow = resolve_plugin_allowlist(plugins) + disabled = {name.strip() for name in (disable or ()) if name.strip()} + loaded: list[str] = [] + catalog = get_catalog() + for entry in _iter_entry_points(): + name = entry.name + if name in disabled: + continue + if allow is not None and name not in allow: + continue + dist = entry.dist + package = dist.name if dist is not None else None + module = entry.value.split(":", 1)[0] if ":" in entry.value else entry.value + source = ToolSource( + kind="plugin", + plugin_id=name, + package=package, + module=module, + ) + with registration_source(source): + # load() may return a callable or module; either is fine as long as + # @tool side effects ran. Call if callable. + obj = entry.load() + if callable(obj): + _ = obj() + # Presence of any tool from this plugin is enough for "loaded". + if any( + entry_tool.source.kind == "plugin" and entry_tool.source.plugin_id == name + for entry_tool in catalog.snapshot().values() + ): + loaded.append(name) + else: + # Entry point ran without registering tools — still count as loaded host-side. + loaded.append(name) + return loaded diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index f625878..3a6a2d1 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -26,6 +26,8 @@ def test_agent_section_defaults(tmp_path: Path) -> None: assert store.agent_config.confirm_destructive is True assert store.agent_config.path_denylist == [] assert store.agent_config.max_context_tokens == 200_000 + assert store.agent_config.tool_plugins == [] + assert store.agent_config.tool_plugins_disable == [] def test_compose_defaults_join_persona_and_directives() -> None: diff --git a/tests/test_tools/test_plugins.py b/tests/test_tools/test_plugins.py new file mode 100644 index 0000000..08c5093 --- /dev/null +++ b/tests/test_tools/test_plugins.py @@ -0,0 +1,63 @@ +"""Plugin allowlist and registration source.""" + +from __future__ import annotations + +from plyngent.agent import tool +from plyngent.tools.catalog import ToolSource, catalog_scope, get_catalog, registration_source +from plyngent.tools.plugins import load_plugin_tools, resolve_plugin_allowlist + + +def test_resolve_plugin_allowlist() -> None: + assert resolve_plugin_allowlist(None) == set() + assert resolve_plugin_allowlist([]) == set() + assert resolve_plugin_allowlist(["*"]) is None + assert resolve_plugin_allowlist(["acme", " beta "]) == {"acme", "beta"} + + +def test_load_plugin_tools_default_loads_none() -> None: + with catalog_scope(empty=True): + loaded = load_plugin_tools(None) + assert loaded == [] + assert get_catalog().names() == [] + + +def test_plugin_registration_source_marks_entries() -> None: + with catalog_scope(empty=True) as catalog: + with registration_source(ToolSource(kind="plugin", plugin_id="acme")): + + @tool(name="acme_ping") + async def acme_ping() -> str: + return "pong" + + _ = acme_ping + + entry = catalog.get("acme_ping") + assert entry is not None + assert entry.source.kind == "plugin" + assert entry.source.plugin_id == "acme" + + +def test_plugin_cannot_shadow_builtin_name() -> None: + with catalog_scope(empty=True) as catalog: + with registration_source(ToolSource(kind="builtin")): + + @tool(name="read_file") + async def builtin_read() -> str: + return "b" + + _ = builtin_read + + try: + with registration_source(ToolSource(kind="plugin", plugin_id="acme")): + + @tool(name="read_file") + async def plugin_read() -> str: + return "p" + + _ = plugin_read + raise AssertionError("expected collision") + except ValueError as exc: + assert "collision" in str(exc) + entry = catalog.get("read_file") + assert entry is not None + assert entry.source.kind == "builtin"