mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/config: top-level [plugins] enable/disable allowlist
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", "*"]
|
||||
|
||||
Reference in New Issue
Block a user