mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/tools: allowlisted entry-point plugin loader
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user