strands
Compose
diff --git a/examples/15_plugins/README.md b/examples/15_plugins/README.md
new file mode 100644
index 0000000..005a8fb
--- /dev/null
+++ b/examples/15_plugins/README.md
@@ -0,0 +1,87 @@
+# 15 — Plugins
+
+> Extend an agent with reusable behaviour packages — skills, context injection, and quality loops.
+
+## What plugins are for
+
+A plugin changes how an agent behaves. Instead of cramming every instruction and
+safeguard into one system prompt, you attach self-contained packages that hook into
+the agent loop. The strands SDK ships several out of the box:
+
+- **[Skills](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/)** — on-demand instructions the agent discovers and loads only when relevant, keeping the base prompt lean.
+- **[Steering](https://strandsagents.com/docs/user-guide/concepts/plugins/steering/)** — just-in-time guidance and guardrails for long, multi-step tasks.
+- **[Context Offloader](https://strandsagents.com/docs/user-guide/concepts/plugins/context-offloader/)** — moves oversized tool results to storage and hands the agent a preview plus a retrieval tool.
+- **[Context Injector](https://strandsagents.com/docs/user-guide/concepts/plugins/context-injector/)** — folds real-time facts (a clock, environment data, a lookup) into each model call without persisting them.
+- **[GoalLoop](https://strandsagents.com/docs/user-guide/concepts/plugins/goal-loop/)** — validates a response and retries with feedback until it meets a quality bar.
+
+See the [strands plugins guide](https://strandsagents.com/docs/user-guide/concepts/plugins/)
+for the full list and how to build your own.
+
+## What this example wires
+
+Three vended plugins, each solving a distinct problem:
+
+```yaml
+plugins:
+ - type: strands:AgentSkills # on-demand skills from ./skills
+ params:
+ skills: ./skills
+
+ - type: ./plugins.py:make_clock_injector # live UTC time, via a factory
+
+ - type: strands.vended_plugins.goal:GoalLoop # retry until the answer is concise
+ params:
+ goal: "Answer in at most three sentences, in plain language with no jargon."
+ max_attempts: 2
+```
+
+Each entry is a `type:` import spec plus optional `params:`. Two forms of import spec work:
+
+- `module.path:Name` — resolved from an installed package (`strands:AgentSkills`, `strands.vended_plugins.goal:GoalLoop`).
+- `./file.py:Name` — resolved from a local file, relative to `config.yaml`.
+
+## Class vs. factory
+
+`GoalLoop` and `AgentSkills` take plain values, so they configure straight from `params`.
+`ContextInjector` needs a *callable* to render the injected text, which a YAML scalar
+can't express. `make_clock_injector` (in `plugins.py`) builds that callable and returns
+the ready plugin. strands-compose calls the resolved object with `params` either way, so
+a factory and a class look identical in the config. Reach for a factory whenever a plugin
+needs a live argument — a callable, a storage backend, a set of providers.
+
+## The skill
+
+`skills/conventional-commits/SKILL.md` teaches the agent to write git commit messages
+that follow the [Conventional Commits](https://www.conventionalcommits.org/) standard —
+procedural knowledge that would bloat a system prompt but belongs in an on-demand skill.
+It is a self-contained skill: YAML frontmatter (`name`, `description`, `license`) plus a
+markdown body with format, steps, examples, and edge cases.
+
+`AgentSkills` injects only the `name` and `description` into the system prompt at startup;
+the full body loads when the agent activates the skill. Skills that ship resource files
+(scripts, references, assets) also need a file-reading tool such as `file_read` from
+[`strands-agents-tools`](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/#providing-tools-for-resource-access) —
+this instruction-only skill needs none.
+
+For the SKILL.md anatomy, the frontmatter fields, and the resource-directory layout,
+see the authoring guide in [Chapter 19 — Plugins](../../docs/configuration/Chapter_19.md#authoring-a-skill-skillmd).
+Agent Skills is an open, cross-vendor format — the authoritative sources are the
+[strands skills docs](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/)
+and the [Agent Skills specification](https://agentskills.io/specification).
+
+## Prerequisites
+
+- AWS credentials configured (`aws configure` or environment variables)
+- Dependencies installed: `uv sync`
+
+## Run
+
+```bash
+uv run python examples/15_plugins/main.py
+```
+
+## Try these prompts
+
+- `Write a commit message for: added pagination to the /users endpoint.` — the agent activates the Conventional Commits skill.
+- `What time is it right now?` — answered from the injected UTC time.
+- `Explain how strands plugins work.` — `GoalLoop` keeps the answer tight and filler-free.
diff --git a/examples/15_plugins/config.yaml b/examples/15_plugins/config.yaml
new file mode 100644
index 0000000..b5f7761
--- /dev/null
+++ b/examples/15_plugins/config.yaml
@@ -0,0 +1,50 @@
+# 15_plugins — Plugins
+#
+# Plugins are reusable packages of agent behaviour: skills, context injection,
+# response-quality loops, steering, context offloading. Each entry under a
+# ``plugins:`` key resolves to a strands.plugins.Plugin and is passed to
+# Agent(plugins=[...]).
+#
+# This config wires three vended plugins, each solving a real problem:
+# 1. AgentSkills — on-demand instructions the agent loads only when needed.
+# 2. ContextInjector — folds the live UTC time into every model call.
+# 3. GoalLoop — retries until the answer meets a quality bar.
+#
+# Run from the example directory:
+# uv run python examples/15_plugins/main.py
+
+models:
+ default:
+ provider: bedrock
+ model_id: openai.gpt-oss-20b-1:0
+
+agents:
+ assistant:
+ model: default
+
+ plugins:
+ # Skills — progressive disclosure. Skill names/descriptions are injected
+ # up front; full instructions load only when the agent activates a skill.
+ # https://strandsagents.com/docs/user-guide/concepts/plugins/skills/
+ - type: strands:AgentSkills
+ params:
+ skills: ./skills
+
+ # Context injection — ContextInjector needs a callable, which a YAML
+ # scalar can't express, so a factory builds it.
+ # The clock text is folded into each model call and never persisted to history.
+ # https://strandsagents.com/docs/user-guide/concepts/plugins/context-injector/
+ - type: ./plugins.py:make_clock_injector
+
+ # Goal loop — validates each response and re-invokes with feedback until
+ # it passes or the attempt budget runs out.
+ # https://strandsagents.com/docs/user-guide/concepts/plugins/goal-loop/
+ - type: strands.vended_plugins.goal:GoalLoop
+ params:
+ goal: "Respond concisely and skip filler, hedging, and restating the question."
+ max_attempts: 2
+
+ system_prompt: |
+ You are a helpful assistant.
+
+entry: assistant
diff --git a/examples/15_plugins/main.py b/examples/15_plugins/main.py
new file mode 100644
index 0000000..ce71430
--- /dev/null
+++ b/examples/15_plugins/main.py
@@ -0,0 +1,63 @@
+"""15_plugins — Plugins.
+
+Wires three vended plugins declared in config.yaml, each solving a real problem:
+
+* ``strands:AgentSkills`` — loads the ``skills/`` directory (a Conventional Commits
+ skill) so the agent pulls in full instructions on demand instead of carrying them
+ in the prompt.
+* ``ContextInjector`` (built by ``make_clock_injector`` in plugins.py) — folds the
+ current UTC time into every model call without persisting it to history.
+* ``GoalLoop`` — re-invokes the agent until its answer meets a quality bar.
+
+Usage:
+ uv run python examples/15_plugins/main.py
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+HERE = Path(__file__).parent
+CONFIG = HERE / "config.yaml"
+STARTER = "Write a commit message for: added pagination to the /users endpoint."
+
+
+def main() -> None:
+ from strands_compose import load
+
+ # AgentSkills reads `skills: ./skills` from plugin params, which strands-compose
+ # forwards verbatim (params paths are not rewritten). Run from the config's
+ # directory so ./skills resolves regardless of where this script is launched.
+ os.chdir(HERE)
+
+ resolved = load(CONFIG)
+ agent = resolved.entry
+ print(f"\n{52 * '-'}")
+ print(f"Try: {STARTER}\n")
+ print("The agent has three plugins wired:")
+ print(" - AgentSkills (a Conventional Commits skill from skills/)")
+ print(" - ContextInjector (live UTC time, built by a factory)")
+ print(" - GoalLoop (retries until the answer is concise)")
+ print("Type a message and press Enter. Empty line to exit.\n")
+ try:
+ while True:
+ msg = input("You: ").strip()
+ if not msg:
+ break
+ print()
+ agent(msg)
+ print("\n" + 52 * "-" + "\n")
+ except KeyboardInterrupt:
+ print("\nGoodbye!")
+ finally:
+ resolved.mcp_lifecycle.stop()
+
+
+# ── entry point ───────────────────────────────────────────────────────────────
+
+if __name__ == "__main__":
+ from strands_compose import cli_errors
+
+ with cli_errors():
+ main()
diff --git a/examples/15_plugins/plugins.py b/examples/15_plugins/plugins.py
new file mode 100644
index 0000000..abc7e31
--- /dev/null
+++ b/examples/15_plugins/plugins.py
@@ -0,0 +1,25 @@
+"""Plugin factory for the 15_plugins example.
+
+``ContextInjector`` takes a render callback rather than a plain value, so it
+cannot be constructed from YAML params alone. A factory builds the callback and
+returns the ready plugin; strands-compose calls the factory exactly as it would
+call a plugin class, so the ``config.yaml`` entry looks identical either way.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+from strands.vended_plugins.context_injector import ContextInjector
+
+
+def _render_utc_clock(_context: Any) -> str:
+ """Render the current UTC time as an injectable context block."""
+ now = datetime.now(timezone.utc).isoformat()
+ return f"
{now}"
+
+
+def make_clock_injector() -> ContextInjector:
+ """Build a ContextInjector that folds the current UTC time into each model call."""
+ return ContextInjector(_render_utc_clock, name="clock")
diff --git a/examples/15_plugins/skills/conventional-commits/SKILL.md b/examples/15_plugins/skills/conventional-commits/SKILL.md
new file mode 100644
index 0000000..13ff82a
--- /dev/null
+++ b/examples/15_plugins/skills/conventional-commits/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: conventional-commits
+description: Write git commit messages that follow the Conventional Commits standard. Use when the user asks for a commit message, or gives a change summary or diff and wants it written up for git.
+license: Apache-2.0
+---
+
+# Conventional Commits
+
+Turn a description of a code change into a commit message that follows the
+[Conventional Commits](https://www.conventionalcommits.org/) standard.
+
+## When to use
+
+Activate when the user asks for a commit message, or hands you a change summary
+or diff and wants it phrased for git.
+
+## Format
+
+```
+
():
+
+
+
+
+```
+
+- **type** — one of `feat`, `fix`, `docs`, `refactor`, `test`, `perf`, `build`, `ci`, `chore`.
+- **scope** — the area touched, e.g. `(api)`, `(auth)`. Optional.
+- **summary** — imperative mood, lowercase, no trailing period, 72 characters or fewer.
+- **body** — what changed and why, wrapped near 72 characters. Optional.
+- **footer** — `BREAKING CHANGE: ...` and issue refs like `Closes #123`. Optional.
+
+## Steps
+
+1. Choose the single `type` that best fits the change. If it does more than one thing, suggest splitting the commit.
+2. Add a `scope` only when it clarifies where the change lives.
+3. Write the summary in the imperative ("add", not "added" or "adds").
+4. Add a body only when the *why* is not obvious from the summary.
+5. Record breaking changes in the footer with `BREAKING CHANGE:` and a short migration hint.
+
+## Examples
+
+Input: "added pagination to the users list endpoint"
+
+```
+feat(api): add pagination to the users list endpoint
+```
+
+Input: "fixed a crash when the config file is missing; now we fall back to defaults"
+
+```
+fix(config): fall back to defaults when the config file is missing
+
+A missing config previously raised at startup. Loading now logs a warning
+and uses the built-in defaults instead.
+```
+
+Input: "stopped reading the AWS_REGION env var; region now comes from config"
+
+```
+refactor(config): drop the AWS_REGION env var in favor of a region field
+
+BREAKING CHANGE: AWS_REGION is no longer read. Set `region:` in your model
+config instead.
+```
+
+## Edge cases
+
+- Reverting a commit — use `revert: ` and put the reverted hash in the body.
+- Several unrelated changes — recommend separate commits rather than one mega-commit.
+- No obvious type — ask what the change is meant to accomplish before guessing.
diff --git a/examples/README.md b/examples/README.md
index 207f11f..c822fdc 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -18,6 +18,7 @@ Each example is a self-contained folder with a `README.md`, `config.yaml`, and `
| 12 | [12_streaming](./12_streaming/) | `wire_event_queue()` — stream every token, tool call, and completion live |
| 13 | [13_graph_conditions](./13_graph_conditions/) | Conditional graph edges — `condition:`, `reset_on_revisit`, `max_node_executions` |
| 14 | [14_agent_factory](./14_agent_factory/) | `type:` + `agent_kwargs:` — custom agent factory instead of default `Agent()` |
+| 15 | [15_plugins](./15_plugins/) | `plugins:` — vended `AgentSkills`, `ContextInjector` (via factory), and `GoalLoop` |
## Prerequisites
diff --git a/pyproject.toml b/pyproject.toml
index 3a3dac0..80a84f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "strands-agents>=1.35.0,<2.0.0",
+ "strands-agents>=1.48.0,<2.0.0",
"pydantic>=2.12.5",
"pyyaml>=6.0.0",
"mcp>=1.24.0",
@@ -36,16 +36,16 @@ agentcore-memory = [
"bedrock-agentcore>=1.4.0",
]
ollama = [
- "strands-agents[ollama]>=1.35.0,<2.0.0",
+ "strands-agents[ollama]>=1.48.0,<2.0.0",
]
openai = [
- "strands-agents[openai]>=1.35.0,<2.0.0",
+ "strands-agents[openai]>=1.48.0,<2.0.0",
]
gemini = [
- "strands-agents[gemini]>=1.35.0,<2.0.0",
+ "strands-agents[gemini]>=1.48.0,<2.0.0",
]
anthropic = [
- "strands-agents[anthropic]>=1.35.0,<2.0.0",
+ "strands-agents[anthropic]>=1.48.0,<2.0.0",
]
[project.urls]
diff --git a/src/strands_compose/config/__init__.py b/src/strands_compose/config/__init__.py
index f6081bb..22ff7de 100644
--- a/src/strands_compose/config/__init__.py
+++ b/src/strands_compose/config/__init__.py
@@ -20,6 +20,7 @@
MCPServerDef,
ModelDef,
OrchestrationDef,
+ PluginDef,
SessionManagerDef,
SwarmOrchestrationDef,
)
@@ -40,6 +41,7 @@
"MCPServerDef",
"ModelDef",
"OrchestrationDef",
+ "PluginDef",
"SessionManagerDef",
"SwarmOrchestrationDef",
"ResolvedConfig",
diff --git a/src/strands_compose/config/loaders/helpers.py b/src/strands_compose/config/loaders/helpers.py
index cd0f2d6..92c5dfa 100644
--- a/src/strands_compose/config/loaders/helpers.py
+++ b/src/strands_compose/config/loaders/helpers.py
@@ -228,6 +228,7 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None:
Fields rewritten:
- ``agents..tools[]`` — tool spec strings
- ``agents..hooks[]`` — string import specs and ``HookDef.type``
+ - ``agents..plugins[]`` — string import specs and ``PluginDef.type``
- ``agents..type`` — custom agent factory path
- ``mcp_servers..type`` — MCP server factory path
- ``models..provider`` — custom model class path
@@ -263,6 +264,19 @@ def rewrite_relative_paths(raw: dict, config_dir: Path) -> None:
if isinstance(hook_type, str):
hook_d["type"] = make_absolute(hook_type, config_dir)
+ # plugins — same shape as hooks. params are opaque kwargs, so any
+ # paths inside them are left as written.
+ plugins = agent_def.get("plugins")
+ if isinstance(plugins, list):
+ for i, plugin in enumerate(plugins):
+ if isinstance(plugin, str):
+ plugins[i] = make_absolute(plugin, config_dir)
+ elif isinstance(plugin, dict):
+ plugin_d = cast(dict[str, Any], plugin)
+ plugin_type = plugin_d.get("type")
+ if isinstance(plugin_type, str):
+ plugin_d["type"] = make_absolute(plugin_type, config_dir)
+
# type
if isinstance(agent_def.get("type"), str):
agent_def["type"] = make_absolute(agent_def["type"], config_dir)
diff --git a/src/strands_compose/config/resolvers/__init__.py b/src/strands_compose/config/resolvers/__init__.py
index ba6da3f..f538daa 100644
--- a/src/strands_compose/config/resolvers/__init__.py
+++ b/src/strands_compose/config/resolvers/__init__.py
@@ -9,6 +9,7 @@
from .mcp import resolve_mcp_client, resolve_mcp_server, resolve_tools
from .models import resolve_model
from .orchestrations import resolve_orchestrations
+from .plugins import resolve_plugin, resolve_plugin_entry
from .session_manager import resolve_session_manager
__all__ = [
@@ -19,6 +20,8 @@
"resolve_infra",
"resolve_hook",
"resolve_hook_entry",
+ "resolve_plugin",
+ "resolve_plugin_entry",
"resolve_mcp_client",
"resolve_mcp_server",
"resolve_model",
diff --git a/src/strands_compose/config/resolvers/agents.py b/src/strands_compose/config/resolvers/agents.py
index 703396f..0eb8f3c 100644
--- a/src/strands_compose/config/resolvers/agents.py
+++ b/src/strands_compose/config/resolvers/agents.py
@@ -13,6 +13,7 @@
from .hooks import resolve_hook_entry
from .mcp import resolve_tools
from .models import resolve_model
+from .plugins import resolve_plugin_entry
from .session_manager import resolve_leaf_session_manager
if TYPE_CHECKING:
@@ -79,8 +80,9 @@ def build_agent_from_def(
# 2. Resolve tool specs
tools = resolve_tools(agent_def.tools)
- # 3. Resolve hooks — module:ClassName or inline HookDef
+ # 3. Resolve hooks and plugins — module:ClassName or inline Def
hooks = [resolve_hook_entry(h) for h in agent_def.hooks]
+ plugins = [resolve_plugin_entry(p) for p in agent_def.plugins]
# 4. MCP clients as tool providers
tool_providers: list[Any] = [mcp_clients[n] for n in agent_def.mcp]
@@ -134,6 +136,7 @@ def build_agent_from_def(
description=agent_def.description,
tools=all_tools,
hooks=all_hooks,
+ plugins=plugins,
conversation_manager=conversation_manager,
session_manager=agent_session,
**agent_def.agent_kwargs,
@@ -147,6 +150,7 @@ def build_agent_from_def(
description=agent_def.description,
tools=all_tools,
hooks=all_hooks,
+ plugins=plugins,
conversation_manager=conversation_manager,
session_manager=agent_session,
load_tools_from_directory=False,
diff --git a/src/strands_compose/config/resolvers/hooks.py b/src/strands_compose/config/resolvers/hooks.py
index 57b12e2..2d879bf 100644
--- a/src/strands_compose/config/resolvers/hooks.py
+++ b/src/strands_compose/config/resolvers/hooks.py
@@ -27,24 +27,17 @@ def resolve_hook(hook_def: HookDef) -> HookProvider:
Instantiated HookProvider.
Raises:
- ValueError: If ``type`` is not in ``module:Class`` format.
+ ImportResolutionError: If ``type`` is not a valid ``module:Class`` /
+ ``./file.py:Class`` spec (a ``ValueError`` subclass, from ``load_object``).
TypeError: If the resolved object is not a HookProvider subclass.
"""
- type_str = hook_def.type
- if ":" not in type_str:
- raise ValueError(
- f"Hook type {type_str!r} is not a valid import spec.\n"
- f"Use 'module.path:ClassName' (e.g. 'strands_compose.hooks:StopGuard') "
- f"or './path/to/file.py:ClassName'."
- )
-
- cls = load_object(type_str, target="hook")
+ cls = load_object(hook_def.type, target="hook")
hook = cls(**hook_def.params)
if not isinstance(hook, HookProvider):
raise TypeError(
- f"Hook {type_str!r} returned {type(hook).__name__}, expected HookProvider subclass."
+ f"Hook {hook_def.type!r} returned {type(hook).__name__}, expected HookProvider subclass."
)
return hook
diff --git a/src/strands_compose/config/resolvers/plugins.py b/src/strands_compose/config/resolvers/plugins.py
new file mode 100644
index 0000000..aed5cc6
--- /dev/null
+++ b/src/strands_compose/config/resolvers/plugins.py
@@ -0,0 +1,62 @@
+"""Resolve PluginDef / plugin entry -> strands.plugins.Plugin instance."""
+
+from __future__ import annotations
+
+from strands.plugins import Plugin
+
+from ...utils import load_object
+from ..schema import PluginDef
+
+
+def resolve_plugin(plugin_def: PluginDef) -> Plugin:
+ """Resolve a PluginDef to a Plugin instance.
+
+ ``type`` must be one of:
+
+ - ``"module.path:ClassName"`` -- full import path (e.g.
+ ``"strands:AgentSkills"``)
+ - ``"./path/to/plugins.py:ClassName"`` -- file-based import
+
+ No short-name aliases are supported. Use the full import path so that
+ submodules and third-party plugins work without ambiguity.
+
+ Args:
+ plugin_def: Plugin definition from YAML.
+
+ Returns:
+ Instantiated Plugin.
+
+ Raises:
+ ImportResolutionError: If ``type`` is not a valid ``module:Class`` /
+ ``./file.py:Class`` spec (a ``ValueError`` subclass, from ``load_object``).
+ TypeError: If calling the resolved object does not produce a Plugin.
+ """
+
+ obj = load_object(plugin_def.type, target="plugin")
+
+ plugin = obj(**plugin_def.params)
+ if not isinstance(plugin, Plugin):
+ raise TypeError(
+ f"Plugin {plugin_def.type!r} returned {type(plugin).__name__}, "
+ f"expected strands.plugins.Plugin subclass."
+ )
+ return plugin
+
+
+def resolve_plugin_entry(entry: PluginDef | str) -> Plugin:
+ """Resolve a single plugin entry from an AgentDef.
+
+ Accepts either:
+
+ - A **string** -- treated directly as a ``module:ClassName`` or
+ ``./file.py:ClassName`` import spec.
+ - An inline **PluginDef** -- resolved via its ``type`` and ``params``.
+
+ Args:
+ entry: Import-path string or inline PluginDef.
+
+ Returns:
+ Instantiated Plugin.
+ """
+ plugin_def = PluginDef(type=entry) if isinstance(entry, str) else entry
+ return resolve_plugin(plugin_def)
diff --git a/src/strands_compose/config/schema.py b/src/strands_compose/config/schema.py
index b198ef7..4f4848b 100644
--- a/src/strands_compose/config/schema.py
+++ b/src/strands_compose/config/schema.py
@@ -38,6 +38,19 @@ class HookDef(BaseModel):
params: dict[str, Any] = Field(default_factory=dict)
+class PluginDef(BaseModel):
+ """Agent plugin reference.
+
+ ``type`` must be a ``module.path:ClassName`` import path or a
+ ``./file.py:ClassName`` file-based import path, resolving to a ``Plugin``
+ subclass or a factory returning one. ``params`` are forwarded as
+ constructor (or factory) kwargs. See ``docs/configuration/Chapter_19.md``.
+ """
+
+ type: str
+ params: dict[str, Any] = Field(default_factory=dict)
+
+
class ConversationManagerDef(BaseModel):
"""Conversation manager configuration.
@@ -135,6 +148,10 @@ class AgentDef(BaseModel):
``hooks`` accepts import-path strings (``"module.path:ClassName"`` or
``"./file.py:ClassName"``) or inline :class:`HookDef` objects with
explicit type + optional params.
+
+ ``plugins`` mirrors ``hooks``: import-path strings (``"module.path:ClassName"``
+ or ``"./file.py:ClassName"``) or inline :class:`PluginDef` objects with
+ explicit type + optional params. See ``docs/configuration/Chapter_19.md``.
"""
type: str | None = None
@@ -148,7 +165,7 @@ class AgentDef(BaseModel):
"""Additional keyword arguments passed to strands.Agent() or custom factory.
Valid Agent parameters: messages, callback_handler,
- record_direct_tool_call, trace_attributes, state, plugins,
+ record_direct_tool_call, trace_attributes, state,
structured_output_prompt, structured_output_model, tool_executor,
retry_strategy, concurrent_invocation_mode, load_tools_from_directory.
@@ -161,6 +178,7 @@ class AgentDef(BaseModel):
description: str | None = None
tools: list[str] = Field(default_factory=list)
hooks: list[HookDef | str] = Field(default_factory=list)
+ plugins: list[PluginDef | str] = Field(default_factory=list)
mcp: list[str] = Field(default_factory=list)
tool_labels: dict[str, str] = Field(default_factory=dict)
conversation_manager: ConversationManagerDef | None = None
diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py
index a412e52..656c1b8 100644
--- a/tests/fakes/__init__.py
+++ b/tests/fakes/__init__.py
@@ -2,12 +2,22 @@
from __future__ import annotations
-from .strands import BoomModel, FakeMCPClient, FakeMCPServer, FakeModel, ToolThenTextModel
+from .strands import (
+ BoomModel,
+ FakeMCPClient,
+ FakeMCPServer,
+ FakeModel,
+ FakePlugin,
+ ToolThenTextModel,
+ fake_plugin_factory,
+)
__all__ = [
"BoomModel",
"FakeMCPClient",
"FakeMCPServer",
"FakeModel",
+ "FakePlugin",
"ToolThenTextModel",
+ "fake_plugin_factory",
]
diff --git a/tests/fakes/strands.py b/tests/fakes/strands.py
index d502266..408fc2e 100644
--- a/tests/fakes/strands.py
+++ b/tests/fakes/strands.py
@@ -11,7 +11,9 @@
from typing import Any
+from strands import tool
from strands.models import Model
+from strands.plugins import Plugin
from strands_compose.mcp.server import MCPServer
@@ -185,6 +187,29 @@ def url(self) -> str:
return self._url
+class FakePlugin(Plugin):
+ """Minimal Plugin that contributes one identifiable ``@tool``.
+
+ ``prefix`` lets a test tell instances apart and appears in the tool result.
+ """
+
+ name = "fake-plugin"
+
+ def __init__(self, *, prefix: str = "") -> None:
+ super().__init__()
+ self.prefix = prefix
+
+ @tool # type: ignore[misc]
+ def fake_plugin_tool(self) -> str:
+ """Signal that the fake plugin is wired."""
+ return f"{self.prefix}ok"
+
+
+def fake_plugin_factory(*, prefix: str = "") -> FakePlugin:
+ """Return a :class:`FakePlugin` — the callable path of plugin resolution."""
+ return FakePlugin(prefix=prefix)
+
+
class FakeMCPClient:
"""Minimal MCP client stand-in for lifecycle ordering tests."""
diff --git a/tests/resolve/test_agents.py b/tests/resolve/test_agents.py
index 0052d23..42d8add 100644
--- a/tests/resolve/test_agents.py
+++ b/tests/resolve/test_agents.py
@@ -41,6 +41,15 @@ def test_named_model_reference_is_wired_onto_the_agent():
assert agent.model is model
+def test_declared_plugin_contributes_its_tool_to_the_agent():
+ # A plugin's @tool reaching agent.tool_names proves the resolved plugins
+ # list is passed to Agent(plugins=[...]) and discovered by strands.
+ agent = build_agent_from_def(
+ "a", agent_def(model="fast", plugins=["tests.fakes:FakePlugin"]), _models(), {}
+ )
+ assert "fake_plugin_tool" in agent.tool_names
+
+
def test_agent_id_matches_the_config_name():
agent = build_agent_from_def("greeter", agent_def(model="fast"), _models(), {})
assert agent.agent_id == "greeter"
diff --git a/tests/resolve/test_plugins.py b/tests/resolve/test_plugins.py
new file mode 100644
index 0000000..b68f171
--- /dev/null
+++ b/tests/resolve/test_plugins.py
@@ -0,0 +1,40 @@
+"""PluginDef / plugin entry -> live strands Plugin resolution."""
+
+from __future__ import annotations
+
+import pytest
+from strands.plugins import Plugin
+
+from strands_compose.config.resolvers.plugins import resolve_plugin, resolve_plugin_entry
+from strands_compose.config.schema import PluginDef
+from strands_compose.exceptions import ImportResolutionError
+from tests.fakes import FakePlugin, fake_plugin_factory # noqa: F401
+
+
+def test_plugin_type_without_colon_raises_import_resolution_error():
+ with pytest.raises(ImportResolutionError):
+ resolve_plugin(PluginDef(type="no_colon_here"))
+
+
+def test_plugin_resolving_to_non_plugin_raises_type_error():
+ with pytest.raises(TypeError):
+ resolve_plugin(PluginDef(type="builtins:dict"))
+
+
+def test_plugin_class_with_params_resolves_to_plugin_instance():
+ plugin = resolve_plugin(PluginDef(type="tests.fakes:FakePlugin", params={"prefix": "hello"}))
+ assert isinstance(plugin, FakePlugin)
+ assert plugin.prefix == "hello"
+
+
+def test_plugin_factory_resolves_to_plugin_instance():
+ plugin = resolve_plugin(
+ PluginDef(type="tests.fakes:fake_plugin_factory", params={"prefix": "via-factory"})
+ )
+ assert isinstance(plugin, FakePlugin)
+ assert plugin.prefix == "via-factory"
+
+
+def test_bare_string_entry_is_treated_as_import_spec():
+ plugin = resolve_plugin_entry("tests.fakes:FakePlugin")
+ assert isinstance(plugin, Plugin)
diff --git a/uv.lock b/uv.lock
index 55e22af..7e22195 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1938,11 +1938,11 @@ requires-dist = [
{ name = "mcp", specifier = ">=1.24.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pyyaml", specifier = ">=6.0.0" },
- { name = "strands-agents", specifier = ">=1.35.0,<2.0.0" },
- { name = "strands-agents", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.35.0,<2.0.0" },
- { name = "strands-agents", extras = ["gemini"], marker = "extra == 'gemini'", specifier = ">=1.35.0,<2.0.0" },
- { name = "strands-agents", extras = ["ollama"], marker = "extra == 'ollama'", specifier = ">=1.35.0,<2.0.0" },
- { name = "strands-agents", extras = ["openai"], marker = "extra == 'openai'", specifier = ">=1.35.0,<2.0.0" },
+ { name = "strands-agents", specifier = ">=1.48.0,<2.0.0" },
+ { name = "strands-agents", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.48.0,<2.0.0" },
+ { name = "strands-agents", extras = ["gemini"], marker = "extra == 'gemini'", specifier = ">=1.48.0,<2.0.0" },
+ { name = "strands-agents", extras = ["ollama"], marker = "extra == 'ollama'", specifier = ">=1.48.0,<2.0.0" },
+ { name = "strands-agents", extras = ["openai"], marker = "extra == 'openai'", specifier = ">=1.48.0,<2.0.0" },
]
provides-extras = ["agentcore-memory", "ollama", "openai", "gemini", "anthropic"]