From 0106e943ae3c7e654f17b5ee78c657ca448eb2f6 Mon Sep 17 00:00:00 2001 From: galuszkm Date: Tue, 21 Jul 2026 22:53:43 +0200 Subject: [PATCH 1/3] feat(plugins): add declarative agent plugin support - add `PluginDef` schema and `plugins:` field on agents (inline object + string shorthand) - resolve entries to live `strands.plugins.Plugin` instances via class or factory, passed to `Agent(plugins=[...])` - rewrite plugin `type:` import specs relative to the config file; leave `params` opaque - remove `plugins` from `agent_kwargs` now that it's first-class - require `strands-agents>=1.48.0` across all extras for the plugins API - document in Chapter 19 + full reference and add runnable `examples/15_plugins` - cover class, factory, non-Plugin, and bad-spec paths in tests with a `FakePlugin` fake --- .../references/project-map.md | 4 +- README.md | 2 +- docs/README.md | 43 ++++ docs/configuration/Chapter_06.md | 4 + docs/configuration/Chapter_18.md | 17 ++ docs/configuration/Chapter_19.md | 202 ++++++++++++++++++ docs/configuration/README.md | 1 + docs/{ => img}/index.html | 2 +- examples/15_plugins/README.md | 87 ++++++++ examples/15_plugins/config.yaml | 50 +++++ examples/15_plugins/main.py | 63 ++++++ examples/15_plugins/plugins.py | 25 +++ .../skills/conventional-commits/SKILL.md | 71 ++++++ examples/README.md | 1 + pyproject.toml | 8 +- src/strands_compose/config/__init__.py | 2 + src/strands_compose/config/loaders/helpers.py | 14 ++ .../config/resolvers/__init__.py | 3 + .../config/resolvers/agents.py | 6 +- .../config/resolvers/plugins.py | 69 ++++++ src/strands_compose/config/schema.py | 20 +- tests/fakes/__init__.py | 12 +- tests/fakes/strands.py | 25 +++ tests/resolve/test_agents.py | 9 + tests/resolve/test_plugins.py | 39 ++++ uv.lock | 8 +- 26 files changed, 772 insertions(+), 15 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/configuration/Chapter_19.md rename docs/{ => img}/index.html (96%) create mode 100644 examples/15_plugins/README.md create mode 100644 examples/15_plugins/config.yaml create mode 100644 examples/15_plugins/main.py create mode 100644 examples/15_plugins/plugins.py create mode 100644 examples/15_plugins/skills/conventional-commits/SKILL.md create mode 100644 src/strands_compose/config/resolvers/plugins.py create mode 100644 tests/resolve/test_plugins.py diff --git a/.kiro/skills/library-development/references/project-map.md b/.kiro/skills/library-development/references/project-map.md index 3235cf8..de65999 100644 --- a/.kiro/skills/library-development/references/project-map.md +++ b/.kiro/skills/library-development/references/project-map.md @@ -112,8 +112,8 @@ for the chapter-by-chapter reference. ## Stack notes - **Python ≥ 3.11** (ruff/ty target 3.13). Runtime deps: `strands-agents` - (>=1.35,<2), `pydantic` v2, `pyyaml`, `mcp`. Optional extras: - `agentcore-memory`, `ollama`, `openai`, `gemini`. + (>=1.48,<2), `pydantic` v2, `pyyaml`, `mcp`. Optional extras: + `agentcore-memory`, `ollama`, `openai`, `gemini`. `anthropic`. - **MCP servers** run on a background daemon thread with a self-managed `uvicorn.Server` (HTTP transports only — `streamable-http`, `sse`); `stdio` is client-side (the client spawns a subprocess). diff --git a/README.md b/README.md index ff858e9..c20dc37 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Python 3.11+ PyPI version - Strands Agents + Strands Agents License

diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..652aeb5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# Strands Compose — Documentation + +**[YAML Configuration Guide](configuration/README.md)** — the complete reference, from your +first one-agent config to nested multi-orchestration systems with MCP servers, hooks, +plugins, session persistence, and streaming. Read it in order, or jump to a chapter: + +| # | Chapter | What it covers | +|---|---------|----------------| +| 1 | [The Basics](configuration/Chapter_01.md) | Your first config | +| 2 | [Models](configuration/Chapter_02.md) | Choosing your LLM | +| 3 | [Variables](configuration/Chapter_03.md) | Environment-driven config | +| 4 | [YAML Anchors](configuration/Chapter_04.md) | DRY config blocks | +| 5 | [Tools](configuration/Chapter_05.md) | Giving agents capabilities | +| 6 | [Hooks](configuration/Chapter_06.md) | Lifecycle middleware | +| 7 | [Session Persistence](configuration/Chapter_07.md) | Memory that survives restarts | +| 8 | [Conversation Managers](configuration/Chapter_08.md) | Controlling context windows | +| 9 | [MCP](configuration/Chapter_09.md) | External tool servers | +| 10 | [Orchestrations](configuration/Chapter_10.md) | Multi-agent systems | +| 11 | [Graph Conditions](configuration/Chapter_11.md) | Dynamic routing | +| 12 | [Nested Orchestrations](configuration/Chapter_12.md) | Composing systems | +| 13 | [Multi-File Configs](configuration/Chapter_13.md) | Splitting and merging | +| 14 | [Agent Factories](configuration/Chapter_14.md) | Custom agent construction | +| 15 | [Event Streaming](configuration/Chapter_15.md) | Real-time observability | +| 16 | [Name Sanitization](configuration/Chapter_16.md) | How names are handled | +| 17 | [The Loading Pipeline](configuration/Chapter_17.md) | What happens under the hood | +| 18 | [Full Reference](configuration/Chapter_18.md) | Every field at a glance | +| 19 | [Plugins](configuration/Chapter_19.md) | Reusable agent behaviors | + +**[Quick Recipes](configuration/Quick_Recipes.md)** — copy-paste-ready configs for common patterns. + +## Learn by example + +Every concept above has a runnable demo in **[examples/](../examples/)** — each a +self-contained folder with a `config.yaml`, a `main.py`, and its own README. + +## What's in this folder + +- `configuration/` — the YAML configuration guide (chapters plus quick recipes). +- `img/` — brand assets + +--- + +Back to the [project README](../README.md). diff --git a/docs/configuration/Chapter_06.md b/docs/configuration/Chapter_06.md index 9cb0eec..cdda7c7 100644 --- a/docs/configuration/Chapter_06.md +++ b/docs/configuration/Chapter_06.md @@ -175,6 +175,10 @@ orchestrations: > - Hooks are the right place for cross-cutting concerns: rate limiting, audit logging, cost tracking, safety guardrails. > - Combine `MaxToolCallsGuard` and `ToolNameSanitizer` as a baseline for any agent that uses tools — they handle the most common edge cases. +## Related: Plugins + +A hook is a single lifecycle callback. A plugin is a reusable behaviour package — [skills](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/), [steering](https://strandsagents.com/docs/user-guide/concepts/plugins/steering/), [context management](https://strandsagents.com/docs/user-guide/concepts/plugins/context-offloader/), [goal loops](https://strandsagents.com/docs/user-guide/concepts/plugins/goal-loop/) — that can bundle hooks, tools, and setup in one object. Reach for a plugin when you want packaged behaviour instead of a one-off callback. See [Chapter 19 — Plugins](Chapter_19.md). + --- [Next: Chapter 7 — Session Persistence →](Chapter_07.md) diff --git a/docs/configuration/Chapter_18.md b/docs/configuration/Chapter_18.md index e115b0d..7b13141 100644 --- a/docs/configuration/Chapter_18.md +++ b/docs/configuration/Chapter_18.md @@ -41,6 +41,7 @@ agents: description: "..." # Agent description (used in orchestration tools) tools: [] # List of tool spec strings hooks: [] # List of HookDef objects or import path strings + plugins: [] # List of PluginDef objects or import path strings mcp: [] # List of MCP client names tool_labels: {} # Tool name -> display label mapping conversation_manager: null # ConversationManagerDef @@ -59,6 +60,22 @@ hooks: - module.path:ClassName ``` +## PluginDef + +A plugin is a reusable behaviour package — skills, context injection, steering, goal loops — +resolved to a `strands.plugins.Plugin` and passed to the agent. See +[Chapter 19 — Plugins](Chapter_19.md) for the full reference. + +```yaml +plugins: + # Inline object form + - type: module.path:ClassName # or ./file.py:ClassName — class or factory + params: {} # Constructor / factory kwargs + + # String shorthand (no params) + - module.path:ClassName +``` + ## SessionManagerDef ```yaml diff --git a/docs/configuration/Chapter_19.md b/docs/configuration/Chapter_19.md new file mode 100644 index 0000000..1ef8479 --- /dev/null +++ b/docs/configuration/Chapter_19.md @@ -0,0 +1,202 @@ +# Chapter 19: Plugins — Reusable Agent Behaviors + +[← Back to Table of Contents](README.md) | [← Previous: Full Reference](Chapter_18.md) + +--- + +A plugin changes how an agent behaves. Rather than pack every instruction and safeguard +into one system prompt, you attach self-contained behaviour packages that plug into the +agent loop. The strands SDK ships several, and you can write your own. The ones you reach +for most often: + +- **[Skills](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/)** — on-demand instructions the agent discovers and loads only when relevant, so the base prompt stays 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 gives 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. + +The [strands plugins guide](https://strandsagents.com/docs/user-guide/concepts/plugins/) +covers the full set and how to build and distribute your own. + +strands-compose does not reinvent any of this. It resolves each `plugins:` entry to one +live `strands.plugins.Plugin` and passes the list to `strands.Agent(plugins=[...])`. The +resolver validates that the result is a `Plugin` and otherwise gets out of the way. + +--- + +## YAML Shape + +A plugin entry is either an **inline object** or a **string shorthand**: + +```yaml +agents: + assistant: + model: default + plugins: + # Inline object — type + optional params + - type: strands:AgentSkills + params: + skills: ./skills + + # String shorthand — no params, just the import spec + - ./plugins.py:MyPlugin +``` + +Both forms accept two import-spec styles: + +- `module.path:Name` — resolved from an installed package (e.g. `strands:AgentSkills`, `strands.vended_plugins.goal:GoalLoop`). +- `./file.py:Name` — resolved from a local file, **relative to the config file**. + +`params` is spread as keyword arguments to the resolved class or factory. + +--- + +## Class vs. Factory + +An entry may resolve to a **class** (a `Plugin` subclass) or a **factory** (any callable +that returns a `Plugin`). strands-compose calls the resolved object with `params` and +checks the result — a non-`Plugin` raises `TypeError`. The config looks the same either way. + +Use a factory when a plugin needs a live argument that YAML can't express — a callable, a +storage backend, a set of providers. `ContextInjector`, for example, takes a render +callback: + +```python +# plugins.py +from datetime import datetime, timezone + +from strands.vended_plugins.context_injector import ContextInjector + + +def make_clock_injector() -> ContextInjector: + """Build a ContextInjector that folds the current UTC time into each model call.""" + def render(_context: object) -> str: + return f"{datetime.now(timezone.utc).isoformat()}" + + return ContextInjector(render, name="clock") +``` + +```yaml +agents: + assistant: + plugins: + - type: ./plugins.py:make_clock_injector # factory — no params needed +``` + +--- + +## Skills + +`strands.AgentSkills` loads a directory of skills and exposes them through progressive +disclosure: each skill's name and description go into the system prompt at startup, and the +full instructions load only when the agent activates the skill. This keeps specialized, +occasionally-needed procedures out of the base prompt. + +```yaml +agents: + skilled_agent: + model: default + system_prompt: "You are a helpful assistant." + plugins: + - type: strands:AgentSkills + params: + skills: ./skills/ # one skill directory, or a parent of several +``` + +> **Paths in `params` are not rewritten.** Unlike a plugin's `type:` (which is resolved +> relative to the config file), everything under `params` is forwarded to the plugin +> verbatim. `skills: ./skills/` therefore resolves against the process working directory — +> run the config from the directory that makes that path valid. + +### Authoring a skill (SKILL.md) + +Agent Skills is an open, cross-vendor format — originated by Anthropic and adopted across +agent tooling. A skill is a directory (named after the skill) containing a `SKILL.md`: YAML +frontmatter followed by a markdown body. It may also ship resource directories the agent +reads on demand. + +``` +conventional-commits/ +├── SKILL.md # required — frontmatter + instructions +├── scripts/ # optional — runnable scripts +├── references/ # optional — docs loaded only when needed +└── assets/ # optional — templates, data, images +``` + +Frontmatter fields, per the [Agent Skills specification](https://agentskills.io/specification): + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | yes | 1–64 chars; lowercase `a–z`, `0–9`, `-`; no leading/trailing or consecutive hyphens. **Must match the directory name.** | +| `description` | yes | 1–1024 chars. State *what* it does **and** *when* to use it, with keywords the model can match — this is all the agent sees until it activates the skill. | +| `license` | no | A license name or a bundled license file. | +| `compatibility` | no | ≤500 chars. Environment requirements (target product, system packages, network access). | +| `metadata` | no | Arbitrary string-to-string map for client-specific data. | +| `allowed-tools` | no | Space-separated pre-approved tool names (experimental; support varies). | + +```markdown +--- +name: conventional-commits +description: Write git commit messages that follow the Conventional Commits standard. Use when the user asks for a commit message or wants a change summary written up for git. +license: Apache-2.0 +--- + +# Conventional Commits + +## When to use +Activate when the user asks for a commit message... + +## Steps +1. Choose the single type that best fits the change... + +## Examples +Input: "added pagination to the users endpoint" -> `feat(api): add pagination to the users endpoint` + +## Edge cases +- Several unrelated changes -> recommend separate commits. +``` + +**Write for progressive disclosure.** The `description` is the model's only cue to activate +the skill, so make it specific. Keep the body focused — the spec recommends under ~500 lines +(≈5000 tokens) — and move long reference material into `references/` files that load only +when the task needs them. + +> **Resource files need a tool.** `AgentSkills` handles discovery and activation only. A +> skill that ships scripts or references also needs 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); +> add it under the agent's `tools:`. Instruction-only skills need none. + +### Further reading + +- [strands — Skills](https://strandsagents.com/docs/user-guide/concepts/plugins/skills/) — how `AgentSkills` discovers, activates, and persists skills (start here). +- [Agent Skills specification](https://agentskills.io/specification) and the [agentskills/agentskills](https://github.com/agentskills/agentskills) repo — the open format itself, plus the `skills-ref` validator. +- [Anthropic — Equipping agents for the real world with Agent Skills](https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills) — the rationale behind the format. + +--- + +## Error Reference + +| Condition | Exception | +|-----------|-----------| +| `type` has no `:` separator | `ValueError` | +| Malformed spec / missing file / module / attribute | `ImportResolutionError` (a `ValueError` subclass, from `load_object`) | +| Resolved object is not callable | `TypeError` | +| Resolved object is callable but does not return a `Plugin` | `TypeError` | +| Constructor or factory raises | the original exception, unwrapped | +| Two plugins on one agent share a `name` | `ValueError`, from the strands plugin registry | +| `plugins` also passed via `agent_kwargs` | `TypeError` (duplicate keyword), from `Agent()` | + +strands-compose adds no plugin-specific exception types; everything propagates unwrapped. + +--- + +> **Tips** +> +> - Every agent gets fresh plugin instances — two agents with the same entry never share state. +> - `plugins:` and `hooks:` can both appear on one agent and fire together. +> - A plugin's `@tool` methods are discoverable on the agent via `agent.tool_names` after construction. +> - Need only a lifecycle callback and no bundled tools or setup? A [hook](Chapter_06.md) is the lighter choice. + +--- + +[← Previous: Full Reference](Chapter_18.md) | [Back to Table of Contents](README.md) diff --git a/docs/configuration/README.md b/docs/configuration/README.md index d508788..4730c26 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -28,6 +28,7 @@ No prior YAML expertise required. We start simple and build up. 16. [Name Sanitization — How Names Are Handled](Chapter_16.md) 17. [The Loading Pipeline — What Happens Under the Hood](Chapter_17.md) 18. [Full Reference — Every Field at a Glance](Chapter_18.md) +19. [Plugins — Reusable Agent Behaviors](Chapter_19.md) **Bonus**: [Quick Recipes](Quick_Recipes.md) — Copy-paste-ready configs for common patterns. diff --git a/docs/index.html b/docs/img/index.html similarity index 96% rename from docs/index.html rename to docs/img/index.html index 6d74499..2beb749 100644 --- a/docs/index.html +++ b/docs/img/index.html @@ -56,7 +56,7 @@
- strands-compose + strands-compose
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..ac519c3 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,13 +36,13 @@ 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", 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/plugins.py b/src/strands_compose/config/resolvers/plugins.py new file mode 100644 index 0000000..fc2b645 --- /dev/null +++ b/src/strands_compose/config/resolvers/plugins.py @@ -0,0 +1,69 @@ +"""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: + ValueError: If ``type`` is not in ``module:Class`` format. + TypeError: If calling the resolved object does not produce a Plugin. + """ + + type_str = plugin_def.type + if ":" not in type_str: + raise ValueError( + f"Plugin type {type_str!r} is not a valid import spec.\n" + f"Use 'module.path:ClassName' (e.g. 'strands:AgentSkills') " + f"or './path/to/file.py:ClassName'." + ) + + obj = load_object(type_str, target="plugin") + + plugin = obj(**plugin_def.params) + if not isinstance(plugin, Plugin): + raise TypeError( + f"Plugin {type_str!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..3db03ef --- /dev/null +++ b/tests/resolve/test_plugins.py @@ -0,0 +1,39 @@ +"""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 tests.fakes import FakePlugin, fake_plugin_factory # noqa: F401 + + +def test_plugin_type_without_colon_raises_value_error(): + with pytest.raises(ValueError, match="import spec"): + resolve_plugin(PluginDef(type="no_colon_here")) + + +def test_plugin_resolving_to_non_plugin_raises_type_error(): + with pytest.raises(TypeError, match="builtins:dict"): + 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..d7d6710 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", specifier = ">=1.48.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", 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"] From 36bb5a9ea8edbe128ffaf69a55f7a1c31e141aeb Mon Sep 17 00:00:00 2001 From: "kiro-agent[bot]" <245459735+kiro-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:20:23 +0200 Subject: [PATCH 2/3] refactor(plugins): pin anthropic to 1.48, drop redundant colon check, trim docs (#77) Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- .../references/project-map.md | 2 +- docs/configuration/Chapter_19.md | 72 +++---------------- pyproject.toml | 2 +- src/strands_compose/config/resolvers/hooks.py | 15 ++-- .../config/resolvers/plugins.py | 15 ++-- uv.lock | 2 +- 6 files changed, 20 insertions(+), 88 deletions(-) diff --git a/.kiro/skills/library-development/references/project-map.md b/.kiro/skills/library-development/references/project-map.md index de65999..2d59341 100644 --- a/.kiro/skills/library-development/references/project-map.md +++ b/.kiro/skills/library-development/references/project-map.md @@ -113,7 +113,7 @@ for the chapter-by-chapter reference. - **Python ≥ 3.11** (ruff/ty target 3.13). Runtime deps: `strands-agents` (>=1.48,<2), `pydantic` v2, `pyyaml`, `mcp`. Optional extras: - `agentcore-memory`, `ollama`, `openai`, `gemini`. `anthropic`. + `agentcore-memory`, `ollama`, `openai`, `gemini`, `anthropic`. - **MCP servers** run on a background daemon thread with a self-managed `uvicorn.Server` (HTTP transports only — `streamable-http`, `sse`); `stdio` is client-side (the client spawns a subprocess). diff --git a/docs/configuration/Chapter_19.md b/docs/configuration/Chapter_19.md index 1ef8479..69ab709 100644 --- a/docs/configuration/Chapter_19.md +++ b/docs/configuration/Chapter_19.md @@ -103,68 +103,15 @@ agents: skills: ./skills/ # one skill directory, or a parent of several ``` -> **Paths in `params` are not rewritten.** Unlike a plugin's `type:` (which is resolved -> relative to the config file), everything under `params` is forwarded to the plugin -> verbatim. `skills: ./skills/` therefore resolves against the process working directory — -> run the config from the directory that makes that path valid. +> **Note — path params are relative to the working directory.** A plugin's `type:` is +> resolved relative to the config file, but any path *inside* `params` (such as a skills +> directory) is forwarded verbatim and resolves against the current working directory. Run +> the config from a directory where that path is valid. (This may change in a future +> release.) -### Authoring a skill (SKILL.md) - -Agent Skills is an open, cross-vendor format — originated by Anthropic and adopted across -agent tooling. A skill is a directory (named after the skill) containing a `SKILL.md`: YAML -frontmatter followed by a markdown body. It may also ship resource directories the agent -reads on demand. - -``` -conventional-commits/ -├── SKILL.md # required — frontmatter + instructions -├── scripts/ # optional — runnable scripts -├── references/ # optional — docs loaded only when needed -└── assets/ # optional — templates, data, images -``` - -Frontmatter fields, per the [Agent Skills specification](https://agentskills.io/specification): - -| Field | Required | Notes | -|-------|----------|-------| -| `name` | yes | 1–64 chars; lowercase `a–z`, `0–9`, `-`; no leading/trailing or consecutive hyphens. **Must match the directory name.** | -| `description` | yes | 1–1024 chars. State *what* it does **and** *when* to use it, with keywords the model can match — this is all the agent sees until it activates the skill. | -| `license` | no | A license name or a bundled license file. | -| `compatibility` | no | ≤500 chars. Environment requirements (target product, system packages, network access). | -| `metadata` | no | Arbitrary string-to-string map for client-specific data. | -| `allowed-tools` | no | Space-separated pre-approved tool names (experimental; support varies). | - -```markdown ---- -name: conventional-commits -description: Write git commit messages that follow the Conventional Commits standard. Use when the user asks for a commit message or wants a change summary written up for git. -license: Apache-2.0 ---- - -# Conventional Commits - -## When to use -Activate when the user asks for a commit message... - -## Steps -1. Choose the single type that best fits the change... - -## Examples -Input: "added pagination to the users endpoint" -> `feat(api): add pagination to the users endpoint` - -## Edge cases -- Several unrelated changes -> recommend separate commits. -``` - -**Write for progressive disclosure.** The `description` is the model's only cue to activate -the skill, so make it specific. Keep the body focused — the spec recommends under ~500 lines -(≈5000 tokens) — and move long reference material into `references/` files that load only -when the task needs them. - -> **Resource files need a tool.** `AgentSkills` handles discovery and activation only. A -> skill that ships scripts or references also needs 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); -> add it under the agent's `tools:`. Instruction-only skills need none. +A skill is just a `SKILL.md` directory that `AgentSkills` discovers. Authoring one — the +frontmatter fields, progressive disclosure, resource files — is owned by strands and the +open Agent Skills format, not by strands-compose; `examples/15_plugins/` ships a working one. ### Further reading @@ -178,8 +125,7 @@ when the task needs them. | Condition | Exception | |-----------|-----------| -| `type` has no `:` separator | `ValueError` | -| Malformed spec / missing file / module / attribute | `ImportResolutionError` (a `ValueError` subclass, from `load_object`) | +| Malformed spec (no `:` separator) / missing file / module / attribute | `ImportResolutionError` (a `ValueError` subclass, from `load_object`) | | Resolved object is not callable | `TypeError` | | Resolved object is callable but does not return a `Plugin` | `TypeError` | | Constructor or factory raises | the original exception, unwrapped | diff --git a/pyproject.toml b/pyproject.toml index ac519c3..80a84f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ gemini = [ "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/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 index fc2b645..aed5cc6 100644 --- a/src/strands_compose/config/resolvers/plugins.py +++ b/src/strands_compose/config/resolvers/plugins.py @@ -27,24 +27,17 @@ def resolve_plugin(plugin_def: PluginDef) -> Plugin: Instantiated Plugin. 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 calling the resolved object does not produce a Plugin. """ - type_str = plugin_def.type - if ":" not in type_str: - raise ValueError( - f"Plugin type {type_str!r} is not a valid import spec.\n" - f"Use 'module.path:ClassName' (e.g. 'strands:AgentSkills') " - f"or './path/to/file.py:ClassName'." - ) - - obj = load_object(type_str, target="plugin") + obj = load_object(plugin_def.type, target="plugin") plugin = obj(**plugin_def.params) if not isinstance(plugin, Plugin): raise TypeError( - f"Plugin {type_str!r} returned {type(plugin).__name__}, " + f"Plugin {plugin_def.type!r} returned {type(plugin).__name__}, " f"expected strands.plugins.Plugin subclass." ) return plugin diff --git a/uv.lock b/uv.lock index d7d6710..7e22195 100644 --- a/uv.lock +++ b/uv.lock @@ -1939,7 +1939,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.5" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "strands-agents", specifier = ">=1.48.0,<2.0.0" }, - { name = "strands-agents", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.35.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" }, From 9c237b64ba1ce846e32c0439c65c8a50976024dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:57:15 +0000 Subject: [PATCH 3/3] test(plugins): assert typed import and stable type errors --- tests/resolve/test_plugins.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/resolve/test_plugins.py b/tests/resolve/test_plugins.py index 3db03ef..b68f171 100644 --- a/tests/resolve/test_plugins.py +++ b/tests/resolve/test_plugins.py @@ -7,16 +7,17 @@ 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_value_error(): - with pytest.raises(ValueError, match="import spec"): +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, match="builtins:dict"): + with pytest.raises(TypeError): resolve_plugin(PluginDef(type="builtins:dict"))