Skip to content

Commit e86fe8e

Browse files
feat(plugins): implement Phase 2 hook integration (#845)
* feat(plugins): implement Phase 2 hook integration - Add hook_modules field to Plugin dataclass - Implement hook discovery in _load_plugin() - Add register_plugin_hooks() function to auto-register hooks - Integrate plugin hooks with init_hooks() - Fix Path conversion for plugin paths - Add comprehensive tests for hook discovery and registration - Update documentation with Phase 2 hooks support Fixes #842 (Phase 2) Co-authored-by: Bob <bob@superuserlabs.org> * style(plugins): convert empty enabled list to None for consistency Addresses Greptile review feedback about style inconsistency between tools and hooks handling of empty enabled_plugins list. Both functions already treat [] and None identically (falsy check), but for clarity and consistency, explicitly convert empty list to None at call site using 'or None' pattern. This makes intent clear: empty config means 'use all plugins' (None behavior) rather than passing falsy-but-not-None value.
1 parent fe8b355 commit e86fe8e

File tree

4 files changed

+290
-10
lines changed

4 files changed

+290
-10
lines changed

docs/plugins.md

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ my_plugin/
1111
├── tools/ # Tool modules (optional)
1212
│ ├── __init__.py # Makes tools/ a package
1313
│ └── my_tool.py # Individual tool modules
14-
├── hooks/ # Hook modules (future)
14+
├── hooks/ # Hook modules (optional)
15+
│ ├── __init__.py # Makes hooks/ a package
16+
│ └── my_hook.py # Individual hook modules
1517
└── commands/ # Command modules (future)
1618

1719

@@ -74,9 +76,15 @@ Hello from my plugin!
7476
## How It Works
7577

7678
1. **Discovery**: gptme searches configured plugin paths for directories with `__init__.py`
77-
2. **Loading**: For each plugin, gptme discovers tool modules in `tools/` subdirectory
78-
3. **Integration**: Plugin tools are loaded using the same mechanism as built-in tools
79-
4. **Availability**: Tools appear in `--tools` list and can be used like built-in tools
79+
2. **Loading**: For each plugin, gptme discovers:
80+
- Tool modules in `tools/` subdirectory
81+
- Hook modules in `hooks/` subdirectory
82+
3. **Integration**:
83+
- Plugin tools are loaded using the same mechanism as built-in tools
84+
- Plugin hooks are registered during initialization via their `register()` functions
85+
4. **Availability**:
86+
- Tools appear in `--tools` list and can be used like built-in tools
87+
- Hooks are automatically triggered at appropriate lifecycle points
8088

8189
## Plugin Tool Modules
8290

@@ -105,6 +113,103 @@ my_plugin/tools/
105113

106114
Each file will be imported as `my_plugin.tools.tool1`, `my_plugin.tools.tool2`, etc.
107115

116+
117+
118+
## Plugin Hook Modules
119+
120+
Plugins can provide hooks to extend gptme's behavior at various lifecycle points, similar to how tools work.
121+
122+
### Option 1: hooks/ as a Package
123+
124+
Create `hooks/__init__.py` and define a `register()` function:
125+
126+
```python
127+
# my_plugin/hooks/__init__.py
128+
from gptme.hooks import HookType, register_hook
129+
from gptme.message import Message
130+
131+
def my_session_hook(logdir, workspace, initial_msgs):
132+
"""Hook called at session start."""
133+
yield Message("system", f"Plugin initialized in workspace: {workspace}")
134+
135+
def register():
136+
"""Register all hooks from this module."""
137+
register_hook(
138+
"my_plugin.session_start",
139+
HookType.SESSION_START,
140+
my_session_hook,
141+
priority=0
142+
)
143+
```
144+
145+
### Option 2: Individual Hook Files
146+
147+
Create individual hook modules without `hooks/__init__.py`:
148+
149+
```python
150+
# my_plugin/hooks/logging_hook.py
151+
from gptme.hooks import HookType, register_hook
152+
from gptme.message import Message
153+
154+
def log_tool_execution(log, workspace, tool_use):
155+
"""Log tool executions."""
156+
print(f"Executing tool: {tool_use.tool}")
157+
yield # Hooks must be generators
158+
159+
def register():
160+
"""Register hooks from this module."""
161+
register_hook(
162+
"my_plugin.log_tool",
163+
HookType.TOOL_PRE_EXECUTE,
164+
log_tool_execution,
165+
priority=0
166+
)
167+
```
168+
169+
### Hook Types
170+
171+
Available hook types:
172+
- `SESSION_START` - Called at session start
173+
- `SESSION_END` - Called at session end
174+
- `TOOL_PRE_EXECUTE` - Before tool execution
175+
- `TOOL_POST_EXECUTE` - After tool execution
176+
- `FILE_PRE_SAVE` - Before saving a file
177+
- `FILE_POST_SAVE` - After saving a file
178+
- `GENERATION_PRE` - Before generating response
179+
- `GENERATION_POST` - After generating response
180+
- And more (see `gptme.hooks.HookType`)
181+
182+
### Hook Registration
183+
184+
Every hook module must have a `register()` function that calls `register_hook()` for each hook it provides. The plugin system automatically calls `register()` during initialization.
185+
186+
## Example: Logging Plugin
187+
188+
A complete example of a plugin that logs tool executions:
189+
190+
```python
191+
# my_logging_plugin/hooks/tool_logger.py
192+
from gptme.hooks import HookType, register_hook
193+
import logging
194+
195+
logger = logging.getLogger(__name__)
196+
197+
def log_tool_pre(log, workspace, tool_use):
198+
"""Log before tool execution."""
199+
logger.info(f"Executing tool: {tool_use.tool} with args: {tool_use.args}")
200+
yield # Hooks must be generators
201+
202+
def log_tool_post(log, workspace, tool_use, result):
203+
"""Log after tool execution."""
204+
logger.info(f"Tool {tool_use.tool} completed")
205+
yield
206+
207+
def register():
208+
register_hook("tool_logger.pre", HookType.TOOL_PRE_EXECUTE, log_tool_pre)
209+
register_hook("tool_logger.post", HookType.TOOL_POST_EXECUTE, log_tool_post)
210+
```
211+
212+
108213
## Example: Weather Plugin
109214

110215
A complete example of a weather information plugin:

gptme/hooks/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Generator
55
from dataclasses import dataclass, field
66
from enum import Enum
7+
from pathlib import Path
78
from time import time
89
from typing import Any, Literal, overload
910

@@ -56,7 +57,6 @@ class HookType(str, Enum):
5657
LOOP_CONTINUE = "loop_continue" # Decide whether/how to continue the chat loop
5758

5859

59-
from pathlib import Path
6060
from typing import TYPE_CHECKING, Protocol
6161

6262
if TYPE_CHECKING:
@@ -565,3 +565,14 @@ def init_hooks() -> None:
565565
markdown_validation.register()
566566
time_awareness.register()
567567
token_awareness.register()
568+
569+
# Register plugin hooks
570+
from ..config import get_config
571+
from ..plugins import register_plugin_hooks
572+
573+
config = get_config()
574+
if config.project and config.project.plugins and config.project.plugins.paths:
575+
register_plugin_hooks(
576+
plugin_paths=[Path(p) for p in config.project.plugins.paths],
577+
enabled_plugins=config.project.plugins.enabled or None,
578+
)

gptme/plugins/__init__.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"Plugin",
2626
"discover_plugins",
2727
"get_plugin_tool_modules",
28+
"register_plugin_hooks",
2829
]
2930

3031

@@ -37,7 +38,8 @@ class Plugin:
3738

3839
# Module names for discovered components
3940
tool_modules: list[str] = field(default_factory=list)
40-
# Future: hook_modules, command_modules
41+
hook_modules: list[str] = field(default_factory=list)
42+
# Future: command_modules
4143

4244

4345
def discover_plugins(plugin_paths: list[Path]) -> list[Plugin]:
@@ -117,10 +119,21 @@ def _load_plugin(plugin_path: Path) -> Plugin | None:
117119
plugin.tool_modules.append(module_name)
118120
logger.debug(f" Found tool module: {module_name}")
119121

120-
# Future: Discover hooks similarly
121-
# hooks_dir = plugin_path / "hooks"
122-
# if hooks_dir.exists() and hooks_dir.is_dir():
123-
# plugin.hook_modules = _discover_hook_modules(plugin_name, hooks_dir)
122+
# Discover hooks
123+
hooks_dir = plugin_path / "hooks"
124+
if hooks_dir.exists() and hooks_dir.is_dir():
125+
if (hooks_dir / "__init__.py").exists():
126+
# hooks/ is a package, register the package module
127+
plugin.hook_modules.append(f"{plugin_name}.hooks")
128+
logger.debug(f" Found hooks package: {plugin_name}.hooks")
129+
else:
130+
# hooks/ is a directory with individual modules
131+
for hook_file in hooks_dir.glob("*.py"):
132+
if hook_file.name.startswith("_"):
133+
continue
134+
module_name = f"{plugin_name}.hooks.{hook_file.stem}"
135+
plugin.hook_modules.append(module_name)
136+
logger.debug(f" Found hook module: {module_name}")
124137

125138
# Future: Discover commands similarly
126139
# commands_dir = plugin_path / "commands"
@@ -161,3 +174,50 @@ def get_plugin_tool_modules(
161174

162175
logger.info(f"Loaded {len(tool_modules)} tool modules from {len(plugins)} plugins")
163176
return tool_modules
177+
178+
179+
def register_plugin_hooks(
180+
plugin_paths: list[Path],
181+
enabled_plugins: list[str] | None = None,
182+
) -> None:
183+
"""
184+
Register hooks from all enabled plugins.
185+
186+
Discovers plugins, imports their hook modules, and calls their register()
187+
functions to register hooks with the gptme hook system.
188+
189+
Args:
190+
plugin_paths: Paths to search for plugins
191+
enabled_plugins: Optional allowlist of plugin names (None = all)
192+
"""
193+
plugins = discover_plugins(plugin_paths)
194+
195+
hooks_registered = 0
196+
for plugin in plugins:
197+
# Apply allowlist if provided
198+
if enabled_plugins and plugin.name not in enabled_plugins:
199+
logger.debug(f"Skipping plugin {plugin.name}: not in allowlist")
200+
continue
201+
202+
# Register hooks from each module
203+
for module_name in plugin.hook_modules:
204+
try:
205+
# Import the hook module
206+
module = __import__(module_name, fromlist=["register"])
207+
208+
# Call the module's register() function if it exists
209+
if hasattr(module, "register"):
210+
module.register()
211+
hooks_registered += 1
212+
logger.debug(f"Registered hooks from {module_name}")
213+
else:
214+
logger.warning(
215+
f"Hook module {module_name} has no register() function"
216+
)
217+
218+
except Exception as e:
219+
logger.error(f"Failed to register hooks from {module_name}: {e}")
220+
221+
logger.info(
222+
f"Registered {hooks_registered} hook modules from {len(plugins)} plugins"
223+
)

tests/test_plugins.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,107 @@ def test_discover_plugins_with_individual_tool_files():
159159
assert "my_plugin.tools.tool1" in plugin.tool_modules
160160
assert "my_plugin.tools.tool2" in plugin.tool_modules
161161
assert "my_plugin.tools._private" not in plugin.tool_modules
162+
163+
164+
def test_plugin_hooks_dataclass():
165+
"""Test Plugin dataclass with hook modules."""
166+
plugin = Plugin(
167+
name="test_plugin",
168+
path=Path("/tmp/test_plugin"),
169+
tool_modules=["test_plugin.tools"],
170+
hook_modules=["test_plugin.hooks"],
171+
)
172+
assert plugin.hook_modules == ["test_plugin.hooks"]
173+
174+
175+
def test_discover_plugins_with_hooks():
176+
"""Test plugin discovery with hooks directory."""
177+
with tempfile.TemporaryDirectory() as tmpdir:
178+
plugin_dir = Path(tmpdir) / "my_plugin"
179+
plugin_dir.mkdir()
180+
181+
# Create __init__.py
182+
(plugin_dir / "__init__.py").write_text("# Plugin init")
183+
184+
# Create hooks directory with __init__.py
185+
hooks_dir = plugin_dir / "hooks"
186+
hooks_dir.mkdir()
187+
(hooks_dir / "__init__.py").write_text("# Hooks init")
188+
189+
# Discover plugins
190+
plugins = discover_plugins([Path(tmpdir)])
191+
192+
assert len(plugins) == 1
193+
assert plugins[0].name == "my_plugin"
194+
assert "my_plugin.hooks" in plugins[0].hook_modules
195+
196+
197+
def test_discover_plugins_with_individual_hook_modules():
198+
"""Test plugin discovery with individual hook module files."""
199+
with tempfile.TemporaryDirectory() as tmpdir:
200+
plugin_dir = Path(tmpdir) / "my_plugin"
201+
plugin_dir.mkdir()
202+
203+
# Create __init__.py
204+
(plugin_dir / "__init__.py").write_text("# Plugin init")
205+
206+
# Create hooks directory without __init__.py
207+
hooks_dir = plugin_dir / "hooks"
208+
hooks_dir.mkdir()
209+
210+
# Create individual hook modules
211+
(hooks_dir / "my_hook.py").write_text("def my_hook(): pass")
212+
(hooks_dir / "another_hook.py").write_text("def another_hook(): pass")
213+
214+
# Discover plugins
215+
plugins = discover_plugins([Path(tmpdir)])
216+
217+
assert len(plugins) == 1
218+
assert "my_plugin.hooks.my_hook" in plugins[0].hook_modules
219+
assert "my_plugin.hooks.another_hook" in plugins[0].hook_modules
220+
221+
222+
def test_register_plugin_hooks():
223+
"""Test plugin hook registration."""
224+
from gptme.hooks import HookType, clear_hooks, get_hooks
225+
from gptme.plugins import register_plugin_hooks
226+
227+
# Clear any existing hooks
228+
clear_hooks()
229+
230+
with tempfile.TemporaryDirectory() as tmpdir:
231+
plugin_dir = Path(tmpdir) / "test_plugin"
232+
plugin_dir.mkdir()
233+
234+
# Create __init__.py
235+
(plugin_dir / "__init__.py").write_text("# Plugin init")
236+
237+
# Create hooks directory with a hook module
238+
hooks_dir = plugin_dir / "hooks"
239+
hooks_dir.mkdir()
240+
241+
# Create a hook module with register() function
242+
hook_code = """
243+
from gptme.hooks import HookType, register_hook
244+
from gptme.message import Message
245+
246+
def test_hook(**kwargs):
247+
yield Message("system", "Test hook executed")
248+
249+
def register():
250+
register_hook(
251+
"test_plugin.test_hook",
252+
HookType.SESSION_START,
253+
test_hook,
254+
priority=0
255+
)
256+
"""
257+
(hooks_dir / "test_hooks.py").write_text(hook_code)
258+
259+
# Register plugin hooks
260+
register_plugin_hooks([Path(tmpdir)])
261+
262+
# Verify hook was registered
263+
hooks = get_hooks(HookType.SESSION_START)
264+
hook_names = [h.name for h in hooks]
265+
assert "test_plugin.test_hook" in hook_names

0 commit comments

Comments
 (0)