Skip to content

feat(plugins): add MultiAgentPlugin for Swarm and Graph orchestrators#2280

Open
zastrowm wants to merge 7 commits into
strands-agents:mainfrom
zastrowm:multi_agent_plugins
Open

feat(plugins): add MultiAgentPlugin for Swarm and Graph orchestrators#2280
zastrowm wants to merge 7 commits into
strands-agents:mainfrom
zastrowm:multi_agent_plugins

Conversation

@zastrowm
Copy link
Copy Markdown
Member

Description

The existing Plugin system only targets individual agents. When building observability, guardrails, or custom behavior for multi-agent orchestrators (Swarm, Graph), there's no composable mechanism — you're forced to manually wire up HookProvider instances and manage initialization yourself. This PR introduces MultiAgentPlugin, the orchestrator-level counterpart to Plugin, giving orchestrators the same declarative plugin experience that agents already have.

Public API Changes

New MultiAgentPlugin base class (exported from strands and strands.plugins):

from strands import MultiAgentPlugin
from strands.plugins import hook
from strands.hooks import BeforeNodeCallEvent, AfterNodeCallEvent

class MonitoringPlugin(MultiAgentPlugin):
    name = "monitoring"

    @hook
    def on_before_node(self, event: BeforeNodeCallEvent):
        print(f"Node {event.node_id} starting")

    @hook
    def on_after_node(self, event: AfterNodeCallEvent):
        print(f"Node {event.node_id} completed")

Both Swarm and Graph accept a new plugins parameter:

from strands.multiagent import Swarm, GraphBuilder

# Swarm
swarm = Swarm(nodes=[agent1, agent2], plugins=[MonitoringPlugin()])

# Graph (via builder)
graph = GraphBuilder()
    .add_node(agent1, node_id="a1")
    .set_entry_point("a1")
    .set_plugins([MonitoringPlugin()])
    .build()

A single class can implement both Plugin and MultiAgentPlugin for dual-use across agents and orchestrators:

class ObservabilityPlugin(Plugin, MultiAgentPlugin):
    name = "observability"

    @hook
    def on_model_call(self, event: BeforeModelCallEvent):
        ...  # Fires when attached to an agent

    @hook
    def on_node_call(self, event: BeforeNodeCallEvent):
        ...  # Fires when attached to an orchestrator

    def init_agent(self, agent): ...
    def init_multi_agent(self, orchestrator): ...

Related Issues

Documentation PR

Type of Change

New feature

Testing

How have you tested the change? Verify that the changes do not break functionality or introduce warnings in consuming repositories: agents-docs, agents-tools, agents-cli

  • I ran hatch run prepare

Checklist

  • I have read the CONTRIBUTING document
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

Codecov Report

❌ Patch coverage is 93.93939% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/strands/plugins/_discovery.py 90.19% 5 Missing ⚠️
src/strands/plugins/plugin.py 60.00% 0 Missing and 2 partials ⚠️
src/strands/multiagent/base.py 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread src/strands/plugins/_discovery.py Outdated
Comment thread src/strands/plugins/multiagent_registry.py Outdated
@github-actions
Copy link
Copy Markdown

Issue: This PR introduces a new public class (MultiAgentPlugin) that customers will directly use and extend. Per the API Bar Raising guidelines, this qualifies as a "moderate change" (adding a new class that customers use to achieve new behavior) and should have the needs-api-review label.

Suggestion: Add the needs-api-review label and ensure an API reviewer evaluates the public surface from a customer perspective before merge.

side_effect=lambda callback, event_type=None: agent.hooks.add_callback(event_type, callback)
)
agent.tool_registry = unittest.mock.MagicMock()
return agent
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: No test covers the TypeError raised when an orchestrator without a hooks attribute is passed to _MultiAgentPluginRegistry.__init__. This is a defensive check (line 61-64) that codecov flags as missing coverage.

Suggestion: Add a test like:

def test_registry_raises_type_error_without_hooks_attribute():
    orch = MagicMock(spec=[])  # no hooks attribute
    del orch.hooks
    with pytest.raises(TypeError, match="does not have a 'hooks' attribute"):
        _MultiAgentPluginRegistry(orch)

Scans the class for methods decorated with @hook and stores references
for later registration when the plugin is attached to an orchestrator.

Uses a guard to prevent double-discovery when used with multiple inheritance
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The double-discovery guard (if not hasattr(self, "_hooks")) is a good defensive measure for multiple inheritance, but there's no test that exercises the code path where _hooks is already set before MultiAgentPlugin.__init__ runs (i.e., when Plugin.__init__ runs first in MRO). The test_dual_plugin_discovers_hooks_once test verifies the result is correct, but doesn't prove the guard is what prevents double-discovery vs. simple MRO ordering.

Suggestion: Consider adding a test that explicitly verifies the guard works by checking that a second __init__ call doesn't overwrite previously discovered hooks (e.g., by mocking discover_hooks and asserting it's called only once).

Comment thread src/strands/plugins/multiagent_registry.py Outdated
logger = logging.getLogger(__name__)


def discover_hooks(instance: object, plugin_name: str) -> list[HookCallback]:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The discover_hooks and discover_tools functions have nearly identical structure (MRO walk, seen-set deduplication, attribute scanning). This is a textbook case for a generic helper that takes a predicate function.

Suggestion: Consider consolidating into a single _discover_methods(instance, plugin_name, predicate, label) helper to eliminate the duplication. For example:

def _discover_methods(instance, plugin_name, predicate, label):
    results = []
    seen = set()
    for cls in reversed(type(instance).__mro__):
        for attr_name in cls.__dict__:
            if attr_name in seen:
                continue
            seen.add(attr_name)
            try:
                bound = getattr(instance, attr_name)
            except Exception:
                continue
            if predicate(bound):
                results.append(bound)
                logger.debug("plugin=<%s>, %s=<%s> | discovered", plugin_name, label, attr_name)
    return results

This is a minor refactoring suggestion and non-blocking.

@github-actions
Copy link
Copy Markdown

Assessment: Comment

Well-designed feature that aligns with the SDK's composability tenet and mirrors the existing Plugin API effectively. The dual-use pattern (both Plugin and MultiAgentPlugin) is particularly well thought-out.

Review Categories
  • API Process: This introduces a new public class customers will extend — should go through API bar-raising review with needs-api-review label per project guidelines.
  • Type Safety: MultiAgentBase doesn't formalize a hooks: HookRegistry contract, leading to runtime hasattr checks and type: ignore comments in the registry. Consider adding hooks to the base class or using a Protocol.
  • Symmetry: The agent-level _PluginRegistry routes hook registration through agent.add_hook() for future-proofing, while the multiagent registry bypasses this by calling registry.add_callback directly. Worth documenting the rationale or aligning the approaches.
  • Test Coverage: Missing coverage for the TypeError guard in _MultiAgentPluginRegistry.__init__ and the double-discovery hasattr guard paths.

Good work extracting shared discovery logic into _discovery.py and keeping the dual-use multiple inheritance behavior clean.

@github-actions github-actions Bot removed the size/xl label May 11, 2026
@zastrowm zastrowm added the needs-api-review Makes changes to the public API surface label May 11, 2026
@github-actions
Copy link
Copy Markdown

Follow-up Review after fix: address review feedback - coverage gaps and param ordering:

The test coverage gaps I flagged have been addressed — the new tests for the TypeError guard and the hasattr double-discovery guard look good. The param ordering fix (placing plugins at the end of the signature after trace_attributes) is the right backward-compatible choice.

The remaining open threads (type safety for MultiAgentBase.hooks, register_hooks asymmetry, and the DRY opportunity in _discovery.py) are design suggestions for consideration — none are blocking.

The needs-api-review label suggestion still stands since this introduces a new public abstract class that customers will directly extend.

Comment thread src/strands/plugins/_discovery.py Outdated
Comment thread src/strands/multiagent/base.py
@github-actions
Copy link
Copy Markdown

Assessment: Comment (close to Approve)

The latest commit (feat: add add_hook to Graph, Swarm, and MultiAgentBase) directly addresses the key design feedback from my earlier reviews — the type safety and registration symmetry concerns are now resolved. The multiagent plugin registry now routes through orchestrator.add_hook() just like the agent-level registry does.

Review Categories
  • Dead code: register_hooks() in _discovery.py is no longer used (responsible for 5/8 codecov misses). Should be removed.
  • Error clarity: MultiAgentBase.add_hook() raises bare NotImplementedError — could benefit from a descriptive message or @abstractmethod to fail fast for custom subclasses.
  • API Process: The needs-api-review label suggestion still applies.

The architecture is now clean and symmetric — both agent and orchestrator plugin registries follow the same pattern of routing hook registration through the owner's public add_hook() method.

@github-actions
Copy link
Copy Markdown

Follow-up Review after refactor: remove redundant hasattr check from plugin registry:

Good simplification — removing the hasattr check makes sense now that MultiAgentBase formally declares add_hook(). The base class NotImplementedError provides a clear failure path if a subclass doesn't override it.

The only remaining item from my previous reviews is the dead register_hooks() function in _discovery.py (lines 102-119) plus the unused HookRegistry import. Once that's cleaned up, this PR looks ready for merge (pending the API review label).

@github-actions
Copy link
Copy Markdown

Assessment: Approve

All feedback from previous review rounds has been addressed. The dead register_hooks function is removed, and MultiAgentBase.add_hook() now provides a clear error message. The architecture is clean, symmetric, and well-tested.

The only remaining codecov gaps are acceptable: the NotImplementedError in MultiAgentBase.add_hook() (base class stub) and the hasattr guard partial branches in plugin.py (defensive code for multiple inheritance). Neither warrants additional tests.

Pending the needs-api-review label for the new public MultiAgentPlugin class, this is ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-api-review Makes changes to the public API surface size/xl

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant