feat(skills): add agent skills as a plugin#1755
feat(skills): add agent skills as a plugin#1755mkmeral wants to merge 23 commits intostrands-agents:mainfrom
Conversation
BREAKING CHANGE: Plugin is now an abstract base class instead of a Protocol. Plugins must explicitly inherit from Plugin and implement the abstract name property and init_plugin method. - Convert Plugin from @runtime_checkable Protocol to ABC - Make name an abstract property - Make init_plugin an abstract method - Update all tests to use inheritance - Maintain support for both sync and async init_plugin - All tests pass (1985 passed) 🤖 Assisted by the code-assist SOP
Implement the SkillsPlugin that adds AgentSkills.io skill support to the Strands Agents SDK. The plugin enables progressive disclosure of skill instructions: metadata is injected into the system prompt upfront, and full instructions are loaded on demand via a tool. Key components: - Skill dataclass with from_path classmethod for loading from SKILL.md - Loader module for discovering, parsing, and validating skills - SkillsPlugin extending the Plugin ABC with: - skills tool (activate/deactivate actions) - BeforeInvocationEvent hook for system prompt injection - AfterInvocationEvent hook for prompt restoration - Single active skill management - Dynamic skill management via property setter - Session persistence via agent.state Files added: - src/strands/plugins/skills/__init__.py - src/strands/plugins/skills/skill.py - src/strands/plugins/skills/loader.py - src/strands/plugins/skills/skills_plugin.py - tests/strands/plugins/skills/ (90 tests) Files modified: - src/strands/plugins/__init__.py (added SkillsPlugin export) - src/strands/__init__.py (added Skill to top-level exports)
- Create @hook decorator for declarative hook registration in plugins - Convert Plugin from Protocol to base class (breaking change) - Add auto-discovery of @hook and @tool decorated methods in Plugin.__init__() - Add auto-registration of hooks and tools in Plugin.init_plugin() - Support union types for multiple event types (e.g., BeforeModelCallEvent | AfterModelCallEvent) - Export hook from strands.plugins and strands namespaces - Update existing tests to use inheritance-based approach - Add comprehensive test coverage for new functionality BREAKING CHANGE: Plugin is now a base class instead of a Protocol. Existing plugins must inherit from Plugin instead of just implementing the protocol.
- Simplify skills tool to single activate action (remove deactivate/action param) - Capture original system prompt once instead of save/restore pattern - Remove AfterInvocationEvent hook (no longer needed) - Replace optional pyyaml with required dependency - Remove _parse_yaml_simple fallback parser - Export Skill and SkillsPlugin from strands top-level
Replace manual hook registration and standalone tool factory with declarative @hook and @tool decorators on SkillsPlugin methods. - Remove _make_skills_tool() standalone function - Convert skills() to @tool decorated instance method - Convert _on_before_invocation() to @hook decorated method - Remove register_hooks() (old HookProvider pattern) - Rename skills property to available_skills (avoids collision) - Delegate hook/tool registration to super().init_plugin() - Update tests to match new API surface
This reverts commit 29f9bf4.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Assessment: Comment This is a well-structured implementation of the AgentSkills.io integration. The code follows repository patterns, has comprehensive test coverage (56 unit + 2 integration tests), and provides a clean progressive disclosure model. Review Categories
Nice progressive disclosure pattern and clean integration with the existing plugin system! 🎯 |
- Escape XML special characters in skill names/descriptions using xml.sax.saxutils.escape to prevent injection/malformed output - Change Skill.metadata type from dict[str, str] to dict[str, Any] to preserve non-string YAML frontmatter values - Make available_skills property setter symmetric (list[Skill] in, list[Skill] out) and add load_skills() method for path resolution - Include available skills list in empty skill_name error message for consistency with the 'not found' error
|
Follow-up Review: Changes Addressed ✅ Thank you for addressing the review feedback! The new commit looks good:
Minor suggestion (optional)Consider adding tests for the new def test_load_skills_merges_with_existing(self, tmp_path):
"""Test that load_skills merges with existing skills."""
plugin = SkillsPlugin(skills=[_make_skill(name="existing-skill")])
_make_skill_dir(tmp_path, "new-skill")
plugin.load_skills([tmp_path / "new-skill"])
assert len(plugin.available_skills) == 2
names = {s.name for s in plugin.available_skills}
assert names == {"existing-skill", "new-skill"}Note: This PR introduces new public classes ( |
|
During adversarial testing, I found one edge case that was missed: Finding:
|
| @property | ||
| def name(self) -> str: | ||
| """A stable string identifier for the plugin.""" | ||
| return "skills" |
There was a problem hiding this comment.
Just curious here, but why @Property over just name = "skills". Its fewer lines of code, and conveys the same information.
There was a problem hiding this comment.
I think this is python related, from my agent:
The Plugin base class defines name as an @abstractmethod property, so subclasses need to implement it as a property
There was a problem hiding this comment.
I tested this locally and it worked:
name = "skills"
Can you make the update?
| """ | ||
| return list(self._skills.values()) | ||
|
|
||
| @available_skills.setter |
There was a problem hiding this comment.
unresolving to ask: Why not have a get_available_skills and set_available_skills? Its a bit more obvious that there is more going on under the hood when you use a method rather than a property
| logger.debug("skill_count=<%d> | skills plugin initialized", len(self._skills)) | ||
|
|
||
| @tool(context=True) | ||
| def skills(self, skill_name: str, tool_context: ToolContext) -> str: # noqa: D417 |
There was a problem hiding this comment.
This single active-skill constraint is quite limiting, and contradicts one of the key-benefits of agent-skills from Claude, i.e. capability composition by combining multiple skills for complex workflows. https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview#why-use-skills.
With this implementation, activating multiple skills would require multiple sequential round-trips between LLM and tool-calls? That'd impact the agent's E2E response latency.
| _DEFAULT_MAX_RESOURCE_FILES = 20 | ||
|
|
||
|
|
||
| class SkillsPlugin(Plugin): |
There was a problem hiding this comment.
from strands.plugins import SkillsPlugin
agent = Agent(plugins=[SkillsPlugin(".")])
This is my minimal code example for skills. Two notes:
- Can we rename
SkillsPlugintoSkills, and maybe rename theSkilldataclass?- Maybe
SkillsPlugin->Skills,Skill->AgentSkill?
- Maybe
- Can you update
skillsto take in a single string, path, or skill, not just a list?
|
|
||
| def __init__( | ||
| self, | ||
| skills: list[str | Path | Skill], |
There was a problem hiding this comment.
Had an odd interaction when using an agent with no skills:
from strands import Agent
from strands.plugins import SkillsPlugin
agent = Agent(plugins=[SkillsPlugin(".")])
while True:
agent(input("\nInput: "))
Interaction:
Input: Hello
Hello! How can I help you today? I have access to various skills that I can activate to assist you with different tasks. Is there something specific you'd like to work on or learn about?
Input: can you list the skills you have access to?
I don't have a direct way to list all available skills, but I can activate specific skills by name using the skills tool. The skills would typically be listed in my system prompt under an "available_skills" section, but I don't currently see that information in my prompt.
If you have a particular task in mind or know the name of a skill you'd like me to use, I can try to activate it for you. Alternatively, you could let me know what you're trying to accomplish, and I can suggest whether there might be a relevant skill available.
What would you like help with today?
Should we raise an error if no skills are available? Should we inform the agent that no skills were loaded? Or should we not inject skill information if there are no skills available?
| if skill.path is not None: | ||
| resources = self._list_skill_resources(skill.path) | ||
| if resources: | ||
| parts.append("\nAvailable resources:\n" + "\n".join(f" {r}" for r in resources)) |
There was a problem hiding this comment.
If the skill result includes resources, can the agent query them? If not, should we even include this if the agent cant do anything with them?
Maybe if a skill has resources, and no way to read them, we log a warning and dont include them?
| if skill.allowed_tools: | ||
| metadata_lines.append(f"Allowed tools: {', '.join(skill.allowed_tools)}") |
There was a problem hiding this comment.
How is an agent intended to use this information? Should we even include it until we understand how it should be used?
For example, if this isnt the right way to expose this information, and we are instead just supposed to attach these tools, removing this information would be a breaking change.
Just general feedback: I think we should be minimal in what information we provide to the Agent until we have a clear idea on how they will integrate with strands.
There was a problem hiding this comment.
With that in mind, should we release this as experimental?
| return skill | ||
|
|
||
|
|
||
| def load_skills(skills_dir: str | Path) -> list[Skill]: |
There was a problem hiding this comment.
Should we consider lenient validation as a follow-up? https://agentskills.io/client-implementation/adding-skills-support#lenient-validation
|
|
||
| skills_xml = self._generate_skills_xml() | ||
| injection = f"\n\n{skills_xml}" | ||
| new_prompt = f"{current_prompt}{injection}" if current_prompt else skills_xml |
There was a problem hiding this comment.
The docs call out system prompt and tools as two different approaches to loading skills. If we want to support loading multiple skills, just returning it in the tool result will probably be easier: https://agentskills.io/client-implementation/adding-skills-support#where-to-place-the-catalog
Then we dont need to maintain state too
Motivation
See design strands-agents/docs#528
This PR adds
SkillsPlugin, a first-class plugin that brings Agent Skills support to Strands. It follows the spec's progressive disclosure model: lightweight metadata (name + description) is injected into the system prompt at startup, and full instructions are loaded on-demand when the agent activates a skill via a tool call. This keeps context usage low while giving agents access to rich, task-specific instructions when needed.Public API Changes
New
SkillsPluginclass andSkilldataclass, both exported from the top-levelstrandspackage:The plugin registers a
skillstool that the agent calls to activate a skill by name. When activated, the tool returns the full instructions along with metadata (allowed tools, compatibility, location) and a listing of available resource files (scripts/,references/,assets/) for filesystem-based skills.Skills can be managed at runtime via a symmetric property and a resolution method:
Skill metadata is injected into the system prompt as XML before each invocation, with special characters escaped:
The active skill selection is persisted to
agent.statefor session recovery.Use Cases
Resolves: #1181
Documentation PR
TBD
Type of Change
New feature
Testing
Manually tested using jupyter notebook and set of skills from
anthropic/skillsrepository100 unit tests covering the plugin, tool, XML generation/escaping, response formatting, resource listing, session persistence, and skill resolution
2 integration tests against a real Bedrock model: model-driven skill activation with codeword verification, and direct tool invocation with state persistence checks
All existing tests (2145 total) continue to pass
I ran
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.