core/tools: allowlisted entry-point plugin loader

This commit is contained in:
2026-07-24 15:09:11 +08:00
parent 07e3c5caa5
commit 96518974be
6 changed files with 171 additions and 2 deletions
+13 -2
View File
@@ -21,7 +21,6 @@ from plyngent.runtime import create_client
from plyngent.tools import ( from plyngent.tools import (
InstanceState, InstanceState,
SessionState, SessionState,
default_tool_definitions,
set_todo_stack, set_todo_stack,
set_workspace_root, set_workspace_root,
) )
@@ -178,9 +177,21 @@ class ReplState:
if not self.tools_enabled: if not self.tools_enabled:
return None return None
from plyngent.cli.limits import prompt_confirm_tool_async 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.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" yolo = self.effective_yolo() != "off"
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode. # Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
return ToolRegistry( return ToolRegistry(
+6
View File
@@ -87,6 +87,12 @@ class AgentConfig(Struct, omit_defaults=True):
path_denylist: list[str] = field(default_factory=list) path_denylist: list[str] = field(default_factory=list)
max_context_tokens: int = 200_000 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). # How to inject todo stack nags into model context (see agent/todo_nag.py).
# developer | user | synthetic_tool | none (legacy "system" → developer) # developer | user | synthetic_tool | none (legacy "system" → developer)
todo_nag_strategy: str = "developer" todo_nag_strategy: str = "developer"
+1
View File
@@ -28,6 +28,7 @@ from .file import move_path as move_path
from .file import read_file as read_file from .file import read_file as read_file
from .file import tree as tree from .file import tree as tree
from .file import write_file as write_file 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 PROCESS_TOOLS as PROCESS_TOOLS
from .process import ask_into_pty as ask_into_pty from .process import ask_into_pty as ask_into_pty
from .process import close_pty as close_pty from .process import close_pty as close_pty
+86
View File
@@ -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
+2
View File
@@ -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.confirm_destructive is True
assert store.agent_config.path_denylist == [] assert store.agent_config.path_denylist == []
assert store.agent_config.max_context_tokens == 200_000 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: def test_compose_defaults_join_persona_and_directives() -> None:
+63
View File
@@ -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"