diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..e9026bf --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,52 @@ +# Plugins + +mini-agent loads external plugins via the `mini_agent.plugins` [entry point](https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/) group. + +## Interface + +```python +from mini_agent import MiniAgentPlugin + +class MyPlugin(MiniAgentPlugin): + def on_session_start(self, session_id: str): ... +``` + +| Hook | When | +|------|------| +| `on_agent_init()` | Startup, before CLI loop | +| `on_session_start(session_id)` | New session, `/new`, `/resume` | +| `on_turn_complete(session_id, history, round_usages)` | After each assistant response saved | +| `on_session_end(session_id, history, round_usages)` | Interactive loop exits | + +## Creating a Plugin + +```toml +[project.entry-points."mini_agent.plugins"] +my-plugin = "my_plugin.plugin:create_plugin" +``` + +```python +# src/my_plugin/plugin.py +from mini_agent import MiniAgentPlugin + +class MyPlugin(MiniAgentPlugin): + def on_session_start(self, session_id: str) -> None: + print(f"Session started: {session_id}") + +def create_plugin(): + return MyPlugin() +``` + +```bash +pip install my-plugin +``` + +## Secrets + +Plugins can store API keys in `~/.mini-agent/.env` (loaded automatically). Shell env vars take precedence. + +```bash +# ~/.mini-agent/.env +LANGFUSE_PUBLIC_KEY=pk-lf-... +LANGFUSE_SECRET_KEY=sk-lf-... +``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 7b0cf37..c4760b9 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -49,3 +49,4 @@ Summarize this repository - [Usage](usage.md) - CLI flags, slash commands, and keyboard shortcuts. - [Providers](providers.md) - authentication and gateway setup. - [Config](config.md) - model and reasoning effort defaults. +- [Plugins](plugins.md) - extending mini-agent with external plugins. diff --git a/docs/usage.md b/docs/usage.md index 2ed4134..b3c1fe8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -47,6 +47,7 @@ mini /resume | `/new` | Start a new session | | `/resume` | Pick from previous sessions | | `/copy` | Copy last assistant message to clipboard | +| `/plugins` | List active plugins | | `/exit`, `q` | Quit | ## Keyboard Shortcuts diff --git a/setup.py b/setup.py index be81c62..9696813 100644 --- a/setup.py +++ b/setup.py @@ -9,8 +9,14 @@ sysconfig._CONFIG_VARS["MACOSX_DEPLOYMENT_TARGET"] = "11.0" +EXCLUDED = {"__init__.py"} +EXCLUDED_PATHS = {"plugin/base.py"} + modules = [ - str(f) for f in Path("src/mini_agent").rglob("*.py") if f.name != "__init__.py" + str(f) + for f in Path("src/mini_agent").rglob("*.py") + if f.name not in EXCLUDED + and str(f.relative_to("src/mini_agent")) not in EXCLUDED_PATHS ] setup( diff --git a/src/mini_agent/__init__.py b/src/mini_agent/__init__.py index 485ca8e..1991136 100644 --- a/src/mini_agent/__init__.py +++ b/src/mini_agent/__init__.py @@ -1,3 +1,5 @@ +"""Mini-agent: a minimal agent.""" + from .cli.main import main __all__ = ["main"] diff --git a/src/mini_agent/cli/display/completion.py b/src/mini_agent/cli/display/completion.py index c7e96ed..7bfcce1 100644 --- a/src/mini_agent/cli/display/completion.py +++ b/src/mini_agent/cli/display/completion.py @@ -17,6 +17,7 @@ "/model": "Select a model", "/copy": "Copy the last assistant response to clipboard", "/status": "Show current session configuration and token usage", + "/plugins": "List currently active plugins", "/exit": "exit the session", } diff --git a/src/mini_agent/cli/main.py b/src/mini_agent/cli/main.py index e15ab8e..80326a3 100644 --- a/src/mini_agent/cli/main.py +++ b/src/mini_agent/cli/main.py @@ -10,6 +10,7 @@ REASONING_EFFORT_LEVELS, config, ) +from ..plugin import PluginManager from .clipboard import copy_last_assistant_text from .display import ( ACCENT_COLOR, @@ -39,18 +40,24 @@ console = Console() session_manager = SessionManager() +plugin_manager = PluginManager.discover() def _run_non_interactive(prompt: str) -> None: history: list[MessageParam] = [{"role": "user", "content": prompt}] session_id = session_manager.new_id() + plugin_manager.on_session_start(session_id) history_len = len(history) agent_loop(history) if len(history) > history_len and token_tracker.get() is not None: session_manager.save(session_id, history, token_tracker.round_usages) + plugin_manager.on_turn_complete(session_id, history, token_tracker.round_usages) -def _run_interactive(prompt: str | None = None, session_id: str | None = None) -> None: +def _run_interactive( + prompt: str | None = None, + session_id: str | None = None, +) -> None: print_welcome_banner() history: list[MessageParam] = [] current_session_id = session_manager.new_id() @@ -73,6 +80,8 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - except StopIteration: print("Session ID not found.\n") + plugin_manager.on_session_start(current_session_id) + while True: try: query = session.prompt(pre_run=pre_run) @@ -93,6 +102,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - if command == "/new": history.clear() current_session_id = session_manager.new_id() + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = 0 next_indicator[0] = 1 token_tracker.reset() @@ -104,6 +114,7 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - current_session_id, history, _ = prompt_resume( session_manager, current_session_id, history ) + plugin_manager.on_session_start(current_session_id) sent_image_count[0] = count_images_in_history(history) next_indicator[0] = max_indicator_in_history(history) + 1 attached_images.clear() @@ -122,6 +133,15 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - print() attached_images.clear() continue + if command == "/plugins": + plugins = plugin_manager.list_plugins() + if plugins: + print(f"Active plugins: {', '.join(plugins)}") + else: + print("No plugins loaded.") + print() + attached_images.clear() + continue content = build_user_content(query, attached_images, sent_image_count) @@ -135,6 +155,13 @@ def _run_interactive(prompt: str | None = None, session_id: str | None = None) - session_manager.save( current_session_id, history, token_tracker.round_usages ) + plugin_manager.on_turn_complete( + current_session_id, history, token_tracker.round_usages + ) + + plugin_manager.on_session_end( + current_session_id, history, token_tracker.round_usages + ) if session_manager.exists(current_session_id): usage_report = format_usage_report(token_tracker.get()) @@ -192,6 +219,8 @@ def main() -> None: ) args = parser.parse_args() + plugin_manager.on_agent_init() + if args.model: config.set_session_model(args.model) diff --git a/src/mini_agent/plugin/__init__.py b/src/mini_agent/plugin/__init__.py new file mode 100644 index 0000000..3c0b8cf --- /dev/null +++ b/src/mini_agent/plugin/__init__.py @@ -0,0 +1,4 @@ +from .base import MiniAgentPlugin +from .manager import PluginManager + +__all__ = ["MiniAgentPlugin", "PluginManager"] diff --git a/src/mini_agent/plugin/base.py b/src/mini_agent/plugin/base.py new file mode 100644 index 0000000..9768ef6 --- /dev/null +++ b/src/mini_agent/plugin/base.py @@ -0,0 +1,26 @@ +"""Base class for mini-agent plugins. + +Zero internal dependencies — safe to import before the rest of +the mini-agent package is fully initialised. +""" + +from typing import Any + + +class MiniAgentPlugin: + """Override lifecycle methods as needed. All default to no-ops.""" + + def on_agent_init(self) -> None: ... + def on_session_start(self, session_id: str) -> None: ... + def on_turn_complete( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... + def on_session_end( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: ... diff --git a/src/mini_agent/plugin/manager.py b/src/mini_agent/plugin/manager.py new file mode 100644 index 0000000..5e2f8a2 --- /dev/null +++ b/src/mini_agent/plugin/manager.py @@ -0,0 +1,57 @@ +"""Plugin discovery and lifecycle dispatch.""" + +import contextlib +import importlib.metadata +import warnings +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class PluginManager: + """Discovers plugins and dispatches lifecycle events.""" + + plugins: list[Any] = field(default_factory=list) + + @staticmethod + def discover() -> PluginManager: + plugins: list[Any] = [] + for ep in importlib.metadata.entry_points(group="mini_agent.plugins"): + try: + plugins.append(ep.load()()) + except Exception as exc: + warnings.warn(f"Failed to load plugin '{ep.name}': {exc}", stacklevel=2) + return PluginManager(plugins=plugins) + + def on_agent_init(self) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_agent_init() + + def on_session_start(self, session_id: str) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_session_start(session_id) + + def on_turn_complete( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_turn_complete(session_id, history, round_usages) + + def on_session_end( + self, + session_id: str, + history: list[dict[str, Any]], + round_usages: list[Any] | None, + ) -> None: + for p in self.plugins: + with contextlib.suppress(Exception): + p.on_session_end(session_id, history, round_usages) + + def list_plugins(self) -> list[str]: + return [type(p).__name__ for p in self.plugins]