core/config: top-level [plugins] enable/disable allowlist

This commit is contained in:
2026-07-24 16:19:50 +08:00
parent 278c930fae
commit 4215a8cea0
9 changed files with 100 additions and 38 deletions
+5 -4
View File
@@ -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.
+2 -2
View File
@@ -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).
+17 -11
View File
@@ -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
+9 -6
View File
@@ -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"
+4 -6
View File
@@ -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(
+1
View File
@@ -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
+15 -6
View File
@@ -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."""
+29 -1
View File
@@ -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()
+18 -2
View File
@@ -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", "*"]