Skip to content

Commit 029c77a

Browse files
feat(hooks): add Plugin Protocol for agent extensibility (#1733)
Co-authored-by: Strands Agent <217235299+strands-agent@users.noreply.github.com>
1 parent a5d26e7 commit 029c77a

7 files changed

Lines changed: 341 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ strands-agents/
126126
│ │ ├── events.py # Hook event definitions
127127
│ │ └── registry.py # Hook registration
128128
│ │
129+
│ ├── plugins/ # Plugin system
130+
│ │ ├── plugin.py # Plugin Protocol definition
131+
│ │ └── registry.py # PluginRegistry for tracking plugins
132+
│ │
129133
│ ├── handlers/ # Event handlers
130134
│ │ └── callback_handler.py # Callback handling
131135
│ │
@@ -171,6 +175,7 @@ strands-agents/
171175
│ ├── session/
172176
│ ├── telemetry/
173177
│ ├── hooks/
178+
│ ├── plugins/
174179
│ ├── handlers/
175180
│ ├── experimental/
176181
│ └── utils/

src/strands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .agent.agent import Agent
55
from .agent.base import AgentBase
66
from .event_loop._retry import ModelRetryStrategy
7+
from .plugins import Plugin
78
from .tools.decorator import tool
89
from .types.tools import ToolContext
910

@@ -13,6 +14,7 @@
1314
"agent",
1415
"models",
1516
"ModelRetryStrategy",
17+
"Plugin",
1618
"tool",
1719
"ToolContext",
1820
"types",

src/strands/plugins/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Plugin system for extending agent functionality.
2+
3+
This module provides a composable mechanism for building objects that can
4+
extend agent behavior through a standardized initialization pattern.
5+
6+
Example Usage:
7+
```python
8+
from strands.plugins import Plugin
9+
10+
class LoggingPlugin:
11+
name = "logging"
12+
13+
def init_plugin(self, agent: Agent) -> None:
14+
agent.add_hook(self.on_model_call, BeforeModelCallEvent)
15+
16+
def on_model_call(self, event: BeforeModelCallEvent) -> None:
17+
print(f"Model called for {event.agent.name}")
18+
```
19+
"""
20+
21+
from .plugin import Plugin
22+
23+
__all__ = [
24+
"Plugin",
25+
]

src/strands/plugins/plugin.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Plugin protocol for extending agent functionality.
2+
3+
This module defines the Plugin Protocol, which provides a composable way to
4+
add behavior changes to agents through a standardized initialization pattern.
5+
"""
6+
7+
from collections.abc import Awaitable
8+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
9+
10+
if TYPE_CHECKING:
11+
from ..agent import Agent
12+
13+
14+
@runtime_checkable
15+
class Plugin(Protocol):
16+
"""Protocol for objects that extend agent functionality.
17+
18+
Plugins provide a composable way to add behavior changes to agents.
19+
They are initialized with an agent instance and can register hooks,
20+
modify agent attributes, or perform other setup tasks.
21+
22+
Attributes:
23+
name: A stable string identifier for the plugin
24+
25+
Example:
26+
```python
27+
class MyPlugin:
28+
name = "my-plugin"
29+
30+
def init_plugin(self, agent: Agent) -> None:
31+
agent.add_hook(self.on_model_call, BeforeModelCallEvent)
32+
```
33+
"""
34+
35+
name: str
36+
37+
def init_plugin(self, agent: "Agent") -> None | Awaitable[None]:
38+
"""Initialize the plugin with an agent instance.
39+
40+
Args:
41+
agent: The agent instance to extend.
42+
"""
43+
...

src/strands/plugins/registry.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Plugin registry for managing plugins attached to an agent.
2+
3+
This module provides the _PluginRegistry class for tracking and managing
4+
plugins that have been initialized with an agent instance.
5+
"""
6+
7+
import inspect
8+
import logging
9+
from collections.abc import Awaitable, Callable
10+
from typing import TYPE_CHECKING, cast
11+
12+
from .._async import run_async
13+
from .plugin import Plugin
14+
15+
if TYPE_CHECKING:
16+
from ..agent import Agent
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class _PluginRegistry:
22+
"""Registry for managing plugins attached to an agent.
23+
24+
The _PluginRegistry tracks plugins that have been initialized with an agent,
25+
providing methods to add plugins and invoke their initialization.
26+
27+
Example:
28+
```python
29+
registry = _PluginRegistry(agent)
30+
31+
class MyPlugin:
32+
name = "my-plugin"
33+
34+
def init_plugin(self, agent: Agent) -> None:
35+
pass
36+
37+
plugin = MyPlugin()
38+
registry.add_and_init(plugin)
39+
```
40+
"""
41+
42+
def __init__(self, agent: "Agent") -> None:
43+
"""Initialize a plugin registry with an agent reference.
44+
45+
Args:
46+
agent: The agent instance that plugins will be initialized with.
47+
"""
48+
self._agent = agent
49+
self._plugins: dict[str, Plugin] = {}
50+
51+
def add_and_init(self, plugin: Plugin) -> None:
52+
"""Add and initialize a plugin with the agent.
53+
54+
This method registers the plugin and calls its init_plugin method.
55+
Handles both sync and async init_plugin implementations automatically.
56+
57+
Args:
58+
plugin: The plugin to add and initialize.
59+
60+
Raises:
61+
ValueError: If a plugin with the same name is already registered.
62+
"""
63+
if plugin.name in self._plugins:
64+
raise ValueError(f"plugin_name=<{plugin.name}> | plugin already registered")
65+
66+
logger.debug("plugin_name=<%s> | registering and initializing plugin", plugin.name)
67+
self._plugins[plugin.name] = plugin
68+
69+
if inspect.iscoroutinefunction(plugin.init_plugin):
70+
async_plugin_init = cast(Callable[..., Awaitable[None]], plugin.init_plugin)
71+
run_async(lambda: async_plugin_init(self._agent))
72+
else:
73+
plugin.init_plugin(self._agent)

tests/strands/plugins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for the plugins module."""
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Tests for the plugin system."""
2+
3+
import unittest.mock
4+
5+
import pytest
6+
7+
from strands.plugins import Plugin
8+
from strands.plugins.registry import _PluginRegistry
9+
10+
# Plugin Protocol Tests
11+
12+
13+
def test_plugin_protocol_is_runtime_checkable():
14+
"""Test that Plugin Protocol is runtime checkable with isinstance."""
15+
16+
class MyPlugin:
17+
name = "my-plugin"
18+
19+
def init_plugin(self, agent):
20+
pass
21+
22+
plugin = MyPlugin()
23+
assert isinstance(plugin, Plugin)
24+
25+
26+
def test_plugin_protocol_sync_implementation():
27+
"""Test Plugin Protocol works with synchronous init_plugin."""
28+
29+
class SyncPlugin:
30+
name = "sync-plugin"
31+
32+
def init_plugin(self, agent):
33+
agent.custom_attribute = "initialized by plugin"
34+
35+
plugin = SyncPlugin()
36+
mock_agent = unittest.mock.Mock()
37+
38+
# Verify the plugin matches the protocol
39+
assert isinstance(plugin, Plugin)
40+
assert plugin.name == "sync-plugin"
41+
42+
# Execute init_plugin synchronously
43+
plugin.init_plugin(mock_agent)
44+
assert mock_agent.custom_attribute == "initialized by plugin"
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_plugin_protocol_async_implementation():
49+
"""Test Plugin Protocol works with asynchronous init_plugin."""
50+
51+
class AsyncPlugin:
52+
name = "async-plugin"
53+
54+
async def init_plugin(self, agent):
55+
agent.custom_attribute = "initialized by async plugin"
56+
57+
plugin = AsyncPlugin()
58+
mock_agent = unittest.mock.Mock()
59+
60+
# Verify the plugin matches the protocol
61+
assert isinstance(plugin, Plugin)
62+
assert plugin.name == "async-plugin"
63+
64+
# Execute init_plugin asynchronously
65+
await plugin.init_plugin(mock_agent)
66+
assert mock_agent.custom_attribute == "initialized by async plugin"
67+
68+
69+
def test_plugin_protocol_requires_name():
70+
"""Test that Plugin Protocol requires a name property."""
71+
72+
class PluginWithoutName:
73+
def init_plugin(self, agent):
74+
pass
75+
76+
plugin = PluginWithoutName()
77+
# A class without 'name' should not pass isinstance check
78+
assert not isinstance(plugin, Plugin)
79+
80+
81+
def test_plugin_protocol_requires_init_plugin_method():
82+
"""Test that Plugin Protocol requires an init_plugin method."""
83+
84+
class PluginWithoutInitPlugin:
85+
name = "incomplete-plugin"
86+
87+
plugin = PluginWithoutInitPlugin()
88+
# A class without 'init_plugin' should not pass isinstance check
89+
assert not isinstance(plugin, Plugin)
90+
91+
92+
def test_plugin_protocol_with_class_attribute_name():
93+
"""Test Plugin Protocol works when name is a class attribute."""
94+
95+
class PluginWithClassAttribute:
96+
name: str = "class-attr-plugin"
97+
98+
def init_plugin(self, agent):
99+
pass
100+
101+
plugin = PluginWithClassAttribute()
102+
assert isinstance(plugin, Plugin)
103+
assert plugin.name == "class-attr-plugin"
104+
105+
106+
def test_plugin_protocol_with_property_name():
107+
"""Test Plugin Protocol works when name is a property."""
108+
109+
class PluginWithProperty:
110+
@property
111+
def name(self):
112+
return "property-plugin"
113+
114+
def init_plugin(self, agent):
115+
pass
116+
117+
plugin = PluginWithProperty()
118+
assert isinstance(plugin, Plugin)
119+
assert plugin.name == "property-plugin"
120+
121+
122+
# _PluginRegistry Tests
123+
124+
125+
@pytest.fixture
126+
def mock_agent():
127+
"""Create a mock agent for testing."""
128+
return unittest.mock.Mock()
129+
130+
131+
@pytest.fixture
132+
def registry(mock_agent):
133+
"""Create a fresh _PluginRegistry for each test."""
134+
return _PluginRegistry(mock_agent)
135+
136+
137+
def test_plugin_registry_add_and_init_calls_init_plugin(registry, mock_agent):
138+
"""Test adding a plugin calls its init_plugin method."""
139+
140+
class TestPlugin:
141+
name = "test-plugin"
142+
143+
def __init__(self):
144+
self.initialized = False
145+
146+
def init_plugin(self, agent):
147+
self.initialized = True
148+
agent.plugin_initialized = True
149+
150+
plugin = TestPlugin()
151+
registry.add_and_init(plugin)
152+
153+
assert plugin.initialized
154+
assert mock_agent.plugin_initialized
155+
156+
157+
def test_plugin_registry_add_duplicate_raises_error(registry, mock_agent):
158+
"""Test that adding a duplicate plugin raises an error."""
159+
160+
class TestPlugin:
161+
name = "test-plugin"
162+
163+
def init_plugin(self, agent):
164+
pass
165+
166+
plugin1 = TestPlugin()
167+
plugin2 = TestPlugin()
168+
169+
registry.add_and_init(plugin1)
170+
171+
with pytest.raises(ValueError, match="plugin_name=<test-plugin> | plugin already registered"):
172+
registry.add_and_init(plugin2)
173+
174+
175+
def test_plugin_registry_add_and_init_with_async_plugin(registry, mock_agent):
176+
"""Test that add_and_init handles async plugins using run_async."""
177+
178+
class AsyncPlugin:
179+
name = "async-plugin"
180+
181+
def __init__(self):
182+
self.initialized = False
183+
184+
async def init_plugin(self, agent):
185+
self.initialized = True
186+
agent.async_plugin_initialized = True
187+
188+
plugin = AsyncPlugin()
189+
registry.add_and_init(plugin)
190+
191+
assert plugin.initialized
192+
assert mock_agent.async_plugin_initialized

0 commit comments

Comments
 (0)