From 4215a8cea02d4f486c33b07e5e07567b5ddda6e9 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 16:19:50 +0800 Subject: [PATCH] core/config: top-level [plugins] enable/disable allowlist --- README.md | 9 ++++---- doc/architecture.md | 4 ++-- doc/plugins.md | 28 ++++++++++++++--------- doc/plyngent.example.toml | 15 ++++++++----- src/plyngent/cli/state.py | 10 ++++----- src/plyngent/config/__init__.py | 1 + src/plyngent/config/models.py | 21 ++++++++++++----- src/plyngent/config/store.py | 30 ++++++++++++++++++++++++- tests/test_config/test_agent_section.py | 20 +++++++++++++++-- 9 files changed, 100 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 6332d88..a8f0d87 100644 --- a/README.md +++ b/README.md @@ -143,14 +143,15 @@ access_key_or_token = "sk-..." # tool_directives = '''### Workspace ...''' confirm_destructive = true max_context_tokens = 200000 -# Optional third-party tools (entry points plyngent.tools); default load none. -# tool_plugins = ["acme"] -# See doc/plugins.md. + +# Optional plugins (entry-point names); default load none. See doc/plugins.md. +# [plugins] +# enable = ["acme"] ``` Per-provider **`timeout`** is passed to the HTTP session for chat/completions, Responses, and `GET /models`. A single number sets one timeout; `{ connect, read }` splits TCP/TLS setup vs idle wait between response bytes (SSE can run longer than `read` while chunks keep arriving). Tool/process timeouts (`run_command`, PTY, policy confirm) are separate. -Third-party **tool plugins**: install a package that declares `project.entry-points."plyngent.tools"`, then allowlist it with `[agent].tool_plugins`. Details: [doc/plugins.md](doc/plugins.md). +Third-party **plugins**: install a package that declares `project.entry-points."plyngent.tools"` (and later other groups), then allowlist the entry-point name under **`[plugins].enable`**. Details: [doc/plugins.md](doc/plugins.md). Supported provider presets today: `openai` (default if `preset` is omitted; default models `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano` when `models` is omitted), `openai-compatible`, `deepseek` (OpenAI convention; default models `deepseek-v4-flash` and `deepseek-v4-pro` if `models` is omitted). Anthropic presets are modeled in config but not wired in the runtime client yet. diff --git a/doc/architecture.md b/doc/architecture.md index ecc5666..58d9db7 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -19,7 +19,7 @@ - components: Utilities for class composition. - memory: Storage controlling for sessions and messages. - router: Multi-source capability routing (Phase H; not implemented). - - config: Plyngent configuration center (TOML). + - config: Plyngent configuration center (TOML), including ``[plugins]``. - agent: Tool loop, streaming, usage, compact; `@tool` / tags / registry. - tools: Workspace file/process/VCS/chat/todo tools; catalog; plugins; instance/session context and views. @@ -27,5 +27,5 @@ - cli: Click entry, slash registry, REPL, one-shot chat. - web: Web service (Phase H; not implemented). -Tool plugins (third-party entry points): [plugins.md](./plugins.md). +Plugins (third-party entry points, allowlisted under ``[plugins]``): [plugins.md](./plugins.md). diff --git a/doc/plugins.md b/doc/plugins.md index 19dfce4..16d50b7 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -97,30 +97,36 @@ pdm add acme-plyngent ## Enable plugins in config -In the user config (`plyngent config path`), under **`[agent]`**: +Plugins are **not** agent-only: allowlisting lives under a top-level **`[plugins]`** +section so future non-tool extensions can reuse the same gate. + +In the user config (`plyngent config path`): ```toml -[agent] +[plugins] # Allowlist of entry-point names. Default empty = load no plugins. -tool_plugins = ["acme"] -# tool_plugins = ["*"] # every discovered plyngent.tools entry point +enable = ["acme"] +# enable = ["*"] # every discovered plyngent.tools entry point # Never load these names even if listed or matched by *: -# tool_plugins_disable = ["legacy"] +# disable = ["legacy"] ``` | Setting | Meaning | |---------|---------| -| `tool_plugins = []` / omitted | Load **no** plugins (safe default). | -| `tool_plugins = ["acme", "other"]` | Load only those entry-point names. | -| `tool_plugins = ["*"]` | Load all discovered plugins. | -| `tool_plugins_disable = ["x"]` | Skip `x` even when allowlisted or `*`. | +| `enable = []` / omitted | Load **no** plugins (safe default). | +| `enable = ["acme", "other"]` | Load only those entry-point names. | +| `enable = ["*"]` | Load all discovered plugins for the group. | +| `disable = ["x"]` | Skip plugin id `x` even when allowlisted or `*`. | + +`enable` / `disable` are **plugin entry-point names** (e.g. `acme`), not individual +tool function names. Restart the chat REPL after changing config so the tool registry rebuilds. CLI load order (simplified): 1. `register_builtin_tools()` -2. `load_plugin_tools(tool_plugins, disable=tool_plugins_disable)` +2. `load_plugin_tools(plugins.enable, disable=plugins.disable)` 3. `catalog.select(surface="local")` → `ToolRegistry` Inspect the model surface in the REPL: @@ -163,7 +169,7 @@ default. | `@tool`, `ToolTag`, `ToolRegistry` | `plyngent.agent` / `plyngent.agent.tools` | | Catalog, `ToolSource`, `select` | `plyngent.tools.catalog` | | `load_plugin_tools` | `plyngent.tools.plugins` | -| Config fields | `AgentConfig.tool_plugins`, `tool_plugins_disable` | +| Config fields | `PluginsConfig.enable`, `PluginsConfig.disable` (`[plugins]`) | ## Related docs diff --git a/doc/plyngent.example.toml b/doc/plyngent.example.toml index 1befe7d..bd4e16a 100644 --- a/doc/plyngent.example.toml +++ b/doc/plyngent.example.toml @@ -10,6 +10,15 @@ # url = "/path/to/chat.db" # # url = ":memory:" +# Third-party plugins (entry-point names). Default: load none. +# Today the CLI loads allowlisted ``plyngent.tools`` entry points; the section is +# not tool-specific so other extension points can share the same allowlist later. +# See doc/plugins.md. +# [plugins] +# enable = ["acme"] +# enable = ["*"] +# disable = ["legacy"] + [agent] # Persona (omit → built-in coding-agent line). tool_directives is the tool playbook. # Override with multi-line literals (prefer ''' so nested " is fine): @@ -38,12 +47,6 @@ max_context_tokens = 200000 # compact_user_prefix = "Summarize the following conversation:\n\n{transcript}" # compact_seed_text = "Compacted from {src}:\n\n{summary}\n\nCarry on." -# Third-party tools (entry-point group plyngent.tools). Default: load none. -# See doc/plugins.md. -# tool_plugins = ["acme"] -# tool_plugins = ["*"] -# tool_plugins_disable = ["legacy"] - # --- OpenAI-compatible (vLLM, LiteLLM, OpenAI, …) --- [providers.openai_compat] preset = "openai-compatible" diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 938df08..ba5d0eb 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -212,17 +212,15 @@ class ReplState: from plyngent.tools.danger import classify_danger from plyngent.tools.plugins import load_plugin_tools - agent_cfg = self.config.agent_config + plugins_cfg = self.config.plugins_config catalog = register_builtin_tools() + # Allowlisted entry points (plugin ids); disable is plugin-id only, not tool names. _ = load_plugin_tools( - agent_cfg.tool_plugins, - disable=agent_cfg.tool_plugins_disable, + plugins_cfg.enable, + disable=plugins_cfg.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/__init__.py b/src/plyngent/config/__init__.py index 19fa9f2..7287b32 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -15,6 +15,7 @@ from .models import HttpTimeoutConfig as HttpTimeoutConfig from .models import ModelConfig as ModelConfig from .models import OpenAICompatibleProvider as OpenAICompatibleProvider from .models import OpenAIProvider as OpenAIProvider +from .models import PluginsConfig as PluginsConfig from .models import ProviderConfig as ProviderConfig from .models import compose_agent_system_content as compose_agent_system_content from .path import get_default_path as _get_default_path diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index ba6acd0..e9fccb9 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -87,12 +87,6 @@ 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" @@ -103,6 +97,21 @@ class AgentConfig(Struct, omit_defaults=True): compact_seed_text: str = "" +class PluginsConfig(Struct, omit_defaults=True): + """Third-party plugins (not tool-specific config). + + Hosts allowlist plugin **entry-point names** (today: group ``plyngent.tools``; + other extension points may reuse the same allowlist later). + + - ``enable`` empty / omitted → load **no** plugins (safe default). + - ``enable = ["*"]`` → load every discovered entry point for that group. + - ``disable`` always wins over ``enable`` / ``*``. + """ + + enable: list[str] = field(default_factory=list) + disable: list[str] = field(default_factory=list) + + class ModelConfig(Struct, omit_defaults=True): """Capability flags for a model within a provider.""" diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index 833d0ff..42b9e0d 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, cast import msgspec import tomlkit -from .models import AgentConfig, DatabaseConfig, ModelConfig, Provider +from .models import AgentConfig, DatabaseConfig, ModelConfig, PluginsConfig, Provider if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping, Sequence @@ -33,6 +33,14 @@ def _parse_agent(raw: dict[str, object]) -> AgentConfig: return AgentConfig() +def _parse_plugins(raw: dict[str, object]) -> PluginsConfig: + """Parse the ``[plugins]`` section, falling back to defaults.""" + try: + return msgspec.convert(raw, PluginsConfig) + except msgspec.ValidationError: + return PluginsConfig() + + def _parse_providers( document: tomlkit.TOMLDocument, ) -> tuple[dict[str, Provider], dict[str, object], dict[str, Provider]]: @@ -100,6 +108,7 @@ class ConfigStore: _document: tomlkit.TOMLDocument _database: DatabaseConfig _agent: AgentConfig + _plugins: PluginsConfig _providers: dict[str, Provider] _bad_providers: dict[str, object] _recoverable_providers: dict[str, Provider] @@ -110,6 +119,7 @@ class ConfigStore: raw: dict[str, object] = document.unwrap() self._database = _parse_database(cast("dict[str, object]", raw.get("database", {}))) self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) + self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document) @property @@ -136,6 +146,18 @@ class ConfigStore: """Typed agent profile (system prompt, tool budgets, etc.).""" return self._agent + # -- plugins (read-only) -- + + @property + def plugins(self) -> MappingProxyType[str, object]: + """Read-only mapping view of plugin allowlist configuration.""" + return MappingProxyType(msgspec.structs.asdict(self._plugins)) + + @property + def plugins_config(self) -> PluginsConfig: + """Typed plugin allowlist (enable / disable entry-point names).""" + return self._plugins + # -- providers (read/write) -- @property @@ -269,6 +291,7 @@ class ConfigStore: raw: dict[str, object] = self._document.unwrap() self._database = _parse_database(cast("dict[str, object]", raw.get("database", {}))) self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) + self._plugins = _parse_plugins(cast("dict[str, object]", raw.get("plugins", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document) # -- internal sync helpers -- @@ -300,6 +323,10 @@ class ConfigStore: """Sync ``[agent]`` to the document.""" self._sync_section("agent", self._agent) + def _sync_plugins_section(self) -> None: + """Sync ``[plugins]`` to the document.""" + self._sync_section("plugins", self._plugins) + def _sync_providers_section(self) -> None: """Sync ``[providers]`` to the document (ready + recoverable).""" section = self._toml_table("providers") @@ -323,4 +350,5 @@ class ConfigStore: """Incrementally sync all sections into the document.""" self._sync_database_section() self._sync_agent_section() + self._sync_plugins_section() self._sync_providers_section() diff --git a/tests/test_config/test_agent_section.py b/tests/test_config/test_agent_section.py index 3a6a2d1..337b912 100644 --- a/tests/test_config/test_agent_section.py +++ b/tests/test_config/test_agent_section.py @@ -26,8 +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 == [] + assert store.plugins_config.enable == [] + assert store.plugins_config.disable == [] def test_compose_defaults_join_persona_and_directives() -> None: @@ -138,3 +138,19 @@ tool_directives = "" ) is None ) + + +def test_plugins_section_parse(tmp_path: Path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text( + """ +[plugins] +enable = ["acme", "*"] +disable = ["legacy"] +""", + encoding="utf-8", + ) + store = load(path) + assert store.plugins_config.enable == ["acme", "*"] + assert store.plugins_config.disable == ["legacy"] + assert store.plugins["enable"] == ["acme", "*"]