-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add plugin system with entry point discovery #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
399741c
3868263
35f9c2e
d7ce20f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-... | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| """Mini-agent: a minimal agent.""" | ||
|
|
||
| from .cli.main import main | ||
|
|
||
| __all__ = ["main"] | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
46
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In non-interactive mode (
Suggested change
Comment on lines
46
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
102
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When starting a new session via the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
114
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly to the
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from .base import MiniAgentPlugin | ||
| from .manager import PluginManager | ||
|
|
||
| __all__ = ["MiniAgentPlugin", "PluginManager"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: ... |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Comment on lines
+26
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silently suppressing all exceptions ( Instead of duplicating def _dispatch(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
for p in self.plugins:
hook = getattr(p, hook_name, None)
if hook is not None:
try:
hook(*args, **kwargs)
except Exception as exc:
warnings.warn(
f"Error executing hook '{hook_name}' on plugin '{type(p).__name__}': {exc}",
stacklevel=3,
)
def on_agent_init(self) -> None:
self._dispatch("on_agent_init")
def on_session_start(self, session_id: str) -> None:
self._dispatch("on_session_start", session_id)
def on_turn_complete(
self,
session_id: str,
history: list[dict[str, Any]],
round_usages: list[Any] | None,
) -> None:
self._dispatch("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:
self._dispatch("on_session_end", session_id, history, round_usages)
Comment on lines
+26
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| def list_plugins(self) -> list[str]: | ||
| return [type(p).__name__ for p in self.plugins] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MiniAgentPluginnot importable from the top-level packageThe plugin docs show
from mini_agent import MiniAgentPluginas the canonical import, but__init__.pyonly re-exportsmain— no reference toMiniAgentPluginexists. Any plugin author following the documented example will getImportError: cannot import name 'MiniAgentPlugin' from 'mini_agent'at install time.