From 1a56e79fe2f344309b60f6d12cb37a37a08ff081 Mon Sep 17 00:00:00 2001 From: Murat Kaan Meral Date: Wed, 25 Feb 2026 12:34:59 -0500 Subject: [PATCH 1/4] feat: add resume field to AfterInvocationEvent for automated agent re-invocation Add a writable 'resume' field (AgentInput | None) to AfterInvocationEvent. When a hook sets resume to a non-None value, the agent automatically re-invokes with that input, triggering a full new invocation cycle including BeforeInvocationEvent. --- pr-description.md | 80 ++++++++++++++++ src/strands/agent/agent.py | 91 +++++++++++------- src/strands/hooks/events.py | 15 +++ tests/strands/agent/hooks/test_events.py | 30 ++++++ tests/strands/agent/test_agent_hooks.py | 117 +++++++++++++++++++++++ workspace/strands-sdk-python | 1 + 6 files changed, 297 insertions(+), 37 deletions(-) create mode 100644 pr-description.md create mode 160000 workspace/strands-sdk-python diff --git a/pr-description.md b/pr-description.md new file mode 100644 index 000000000..29fca03e8 --- /dev/null +++ b/pr-description.md @@ -0,0 +1,80 @@ +## Description + +### Motivation + +See design https://github.com/strands-agents/docs/pull/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 `SkillsPlugin` class and `Skill` dataclass, both exported from the top-level `strands` package: + +```python +from strands import Agent, Skill +from strands.plugins.skills import SkillsPlugin + +# Load skills from filesystem (individual dirs or parent dirs) +plugin = SkillsPlugin(skills=["./skills/pdf-processing", "./skills/"]) + +# Or provide Skill instances directly +skill = Skill(name="my-skill", description="A custom skill", instructions="Do the thing") +plugin = SkillsPlugin(skills=[skill]) + +agent = Agent(plugins=[plugin]) +``` + +The plugin registers a `skills` tool 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. + +Skill metadata is injected into the system prompt as XML before each invocation, following the recommended format from the integration spec: + +```xml + + + pdf-processing + Extract text and tables from PDF files. + /path/to/pdf-processing/SKILL.md + + +``` + +The active skill selection is persisted to `agent.state` for session recovery. + +### Use Cases + +- **Skill libraries**: Point the plugin at a directory of skills and let the agent pick the right one based on the user's request +- **Dynamic specialization**: Swap agent behavior at runtime without rebuilding prompts or agents +- **Portable skills**: Share skill directories across teams and agents using the Agent Skills standard format + +## Related Issues + +https://github.com/strands-agents/sdk-python/issues/1181 + +## Documentation PR + +TBD + +## Type of Change + +New feature + +## Testing + +- Manually tested using jupyter notebook and set of skills from `anthropic/skills` repository +- 56 unit tests covering the plugin, tool, XML generation, 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 plugin tests (136 total) continue to pass + +- [x] I ran `hatch run prepare` + +## Checklist +- [x] I have read the CONTRIBUTING document +- [x] I have added any necessary tests that prove my fix is effective or my feature works +- [ ] I have updated the documentation accordingly +- [ ] I have added an appropriate example to the documentation to outline the feature, or no new docs are needed +- [x] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published + +---- + +By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index ebead3b7d..74bac3616 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -768,6 +768,10 @@ async def _run_loop( ) -> AsyncGenerator[TypedEvent, None]: """Execute the agent's event loop with the given message and parameters. + Supports automatic resume: if a hook sets ``resume`` on the AfterInvocationEvent + to a non-None agent input, the loop re-invokes with that input, triggering a full + new invocation cycle including BeforeInvocationEvent. + Args: messages: The input messages to add to the conversation. invocation_state: Additional parameters to pass to the event loop. @@ -777,49 +781,62 @@ async def _run_loop( Yields: Events from the event loop cycle. """ - before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async( - BeforeInvocationEvent(agent=self, invocation_state=invocation_state, messages=messages) - ) - messages = before_invocation_event.messages if before_invocation_event.messages is not None else messages + current_messages: Messages | None = messages - agent_result: AgentResult | None = None - try: - yield InitEventLoopEvent() + while current_messages is not None: + before_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async( + BeforeInvocationEvent(agent=self, invocation_state=invocation_state, messages=current_messages) + ) + current_messages = ( + before_invocation_event.messages if before_invocation_event.messages is not None else current_messages + ) - await self._append_messages(*messages) + agent_result: AgentResult | None = None + try: + yield InitEventLoopEvent() - structured_output_context = StructuredOutputContext( - structured_output_model or self._default_structured_output_model, - structured_output_prompt=structured_output_prompt or self._structured_output_prompt, - ) + await self._append_messages(*current_messages) - # Execute the event loop cycle with retry logic for context limits - events = self._execute_event_loop_cycle(invocation_state, structured_output_context) - async for event in events: - # Signal from the model provider that the message sent by the user should be redacted, - # likely due to a guardrail. - if ( - isinstance(event, ModelStreamChunkEvent) - and event.chunk - and event.chunk.get("redactContent") - and event.chunk["redactContent"].get("redactUserContentMessage") - ): - self.messages[-1]["content"] = self._redact_user_content( - self.messages[-1]["content"], str(event.chunk["redactContent"]["redactUserContentMessage"]) - ) - if self._session_manager: - self._session_manager.redact_latest_message(self.messages[-1], self) - yield event + structured_output_context = StructuredOutputContext( + structured_output_model or self._default_structured_output_model, + structured_output_prompt=structured_output_prompt or self._structured_output_prompt, + ) - # Capture the result from the final event if available - if isinstance(event, EventLoopStopEvent): - agent_result = AgentResult(*event["stop"]) + # Execute the event loop cycle with retry logic for context limits + events = self._execute_event_loop_cycle(invocation_state, structured_output_context) + async for event in events: + # Signal from the model provider that the message sent by the user should be redacted, + # likely due to a guardrail. + if ( + isinstance(event, ModelStreamChunkEvent) + and event.chunk + and event.chunk.get("redactContent") + and event.chunk["redactContent"].get("redactUserContentMessage") + ): + self.messages[-1]["content"] = self._redact_user_content( + self.messages[-1]["content"], + str(event.chunk["redactContent"]["redactUserContentMessage"]), + ) + if self._session_manager: + self._session_manager.redact_latest_message(self.messages[-1], self) + yield event + + # Capture the result from the final event if available + if isinstance(event, EventLoopStopEvent): + agent_result = AgentResult(*event["stop"]) - finally: - self.conversation_manager.apply_management(self) - await self.hooks.invoke_callbacks_async( - AfterInvocationEvent(agent=self, invocation_state=invocation_state, result=agent_result) - ) + finally: + self.conversation_manager.apply_management(self) + after_invocation_event, _interrupts = await self.hooks.invoke_callbacks_async( + AfterInvocationEvent(agent=self, invocation_state=invocation_state, result=agent_result) + ) + + # Convert resume input to messages for next iteration, or None to stop + if after_invocation_event.resume is not None: + logger.debug("resume= | hook requested agent resume with new input") + current_messages = await self._convert_prompt_to_messages(after_invocation_event.resume) + else: + current_messages = None async def _execute_event_loop_cycle( self, invocation_state: dict[str, Any], structured_output_context: StructuredOutputContext | None = None diff --git a/src/strands/hooks/events.py b/src/strands/hooks/events.py index 8d3e5d280..9186e0e70 100644 --- a/src/strands/hooks/events.py +++ b/src/strands/hooks/events.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from ..agent.agent_result import AgentResult +from ..types.agent import AgentInput from ..types.content import Message, Messages from ..types.interrupt import _Interruptible from ..types.streaming import StopReason @@ -78,6 +79,13 @@ class AfterInvocationEvent(HookEvent): - Agent.stream_async - Agent.structured_output + Resume: + When ``resume`` is set to a non-None value by a hook callback, the agent will + automatically re-invoke itself with the provided input. This enables hooks to + implement autonomous looping patterns where the agent continues processing + based on its previous result. The resume triggers a full new invocation cycle + including ``BeforeInvocationEvent``. + Attributes: invocation_state: State and configuration passed through the agent invocation. This can include shared context for multi-agent coordination, request tracking, @@ -85,10 +93,17 @@ class AfterInvocationEvent(HookEvent): result: The result of the agent invocation, if available. This will be None when invoked from structured_output methods, as those return typed output directly rather than AgentResult. + resume: When set to a non-None agent input by a hook callback, the agent will + re-invoke itself with this input. The value can be any valid AgentInput + (str, content blocks, messages, etc.). Defaults to None (no resume). """ invocation_state: dict[str, Any] = field(default_factory=dict) result: "AgentResult | None" = None + resume: AgentInput = None + + def _can_write(self, name: str) -> bool: + return name == "resume" @property def should_reverse_callbacks(self) -> bool: diff --git a/tests/strands/agent/hooks/test_events.py b/tests/strands/agent/hooks/test_events.py index de551d137..0e03fbbcd 100644 --- a/tests/strands/agent/hooks/test_events.py +++ b/tests/strands/agent/hooks/test_events.py @@ -230,3 +230,33 @@ def test_before_invocation_event_agent_not_writable(start_request_event_with_mes """Test that BeforeInvocationEvent.agent is not writable.""" with pytest.raises(AttributeError, match="Property agent is not writable"): start_request_event_with_messages.agent = Mock() + + +def test_after_invocation_event_resume_defaults_to_none(agent): + """Test that AfterInvocationEvent.resume defaults to None.""" + event = AfterInvocationEvent(agent=agent, result=None) + assert event.resume is None + + +def test_after_invocation_event_resume_is_writable(agent): + """Test that AfterInvocationEvent.resume can be set by hooks.""" + event = AfterInvocationEvent(agent=agent, result=None) + event.resume = "continue with this input" + assert event.resume == "continue with this input" + + +def test_after_invocation_event_resume_accepts_various_input_types(agent): + """Test that resume accepts all AgentInput types.""" + event = AfterInvocationEvent(agent=agent, result=None) + + # String input + event.resume = "hello" + assert event.resume == "hello" + + # Content block list + event.resume = [{"text": "hello"}] + assert event.resume == [{"text": "hello"}] + + # None to stop + event.resume = None + assert event.resume is None diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 4397b9628..4ce971b03 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -694,3 +694,120 @@ async def capture_messages_hook(event: BeforeInvocationEvent): # structured_output_async uses deprecated path that doesn't pass messages assert received_messages is None + + +def test_after_invocation_resume_triggers_new_invocation(): + """Test that setting resume on AfterInvocationEvent re-invokes the agent.""" + mock_provider = MockedModelProvider( + [ + {"role": "assistant", "content": [{"text": "First response"}]}, + {"role": "assistant", "content": [{"text": "Second response"}]}, + ] + ) + + resume_count = 0 + + async def resume_once(event: AfterInvocationEvent): + nonlocal resume_count + if resume_count == 0: + resume_count += 1 + event.resume = "continue" + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(AfterInvocationEvent, resume_once) + + result = agent("start") + + # Agent should have been invoked twice + assert resume_count == 1 + assert result.message["content"][0]["text"] == "Second response" + # 4 messages: user1, assistant1, user2 (resume), assistant2 + assert len(agent.messages) == 4 + assert agent.messages[0]["content"][0]["text"] == "start" + assert agent.messages[2]["content"][0]["text"] == "continue" + + +def test_after_invocation_resume_none_does_not_loop(): + """Test that resume=None (default) does not re-invoke the agent.""" + mock_provider = MockedModelProvider( + [ + {"role": "assistant", "content": [{"text": "Only response"}]}, + ] + ) + + call_count = 0 + + async def no_resume(event: AfterInvocationEvent): + nonlocal call_count + call_count += 1 + # Don't set resume - should remain None + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(AfterInvocationEvent, no_resume) + + result = agent("hello") + + assert call_count == 1 + assert result.message["content"][0]["text"] == "Only response" + + +def test_after_invocation_resume_fires_before_invocation_event(): + """Test that resume triggers BeforeInvocationEvent on each iteration.""" + mock_provider = MockedModelProvider( + [ + {"role": "assistant", "content": [{"text": "First"}]}, + {"role": "assistant", "content": [{"text": "Second"}]}, + ] + ) + + before_invocation_count = 0 + after_invocation_count = 0 + + async def count_before(event: BeforeInvocationEvent): + nonlocal before_invocation_count + before_invocation_count += 1 + + async def resume_once(event: AfterInvocationEvent): + nonlocal after_invocation_count + after_invocation_count += 1 + if after_invocation_count == 1: + event.resume = "next" + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(BeforeInvocationEvent, count_before) + agent.hooks.add_callback(AfterInvocationEvent, resume_once) + + agent("start") + + # BeforeInvocationEvent should fire for both the initial and resumed invocation + assert before_invocation_count == 2 + assert after_invocation_count == 2 + + +def test_after_invocation_resume_multiple_times(): + """Test that resume can chain multiple re-invocations.""" + mock_provider = MockedModelProvider( + [ + {"role": "assistant", "content": [{"text": "Response 1"}]}, + {"role": "assistant", "content": [{"text": "Response 2"}]}, + {"role": "assistant", "content": [{"text": "Response 3"}]}, + ] + ) + + resume_count = 0 + + async def resume_twice(event: AfterInvocationEvent): + nonlocal resume_count + if resume_count < 2: + resume_count += 1 + event.resume = f"iteration {resume_count + 1}" + + agent = Agent(model=mock_provider) + agent.hooks.add_callback(AfterInvocationEvent, resume_twice) + + result = agent("iteration 1") + + assert resume_count == 2 + assert result.message["content"][0]["text"] == "Response 3" + # 6 messages: 3 user + 3 assistant + assert len(agent.messages) == 6 diff --git a/workspace/strands-sdk-python b/workspace/strands-sdk-python new file mode 160000 index 000000000..c4503d1f3 --- /dev/null +++ b/workspace/strands-sdk-python @@ -0,0 +1 @@ +Subproject commit c4503d1f37400d26a87947d006b39d114ed7c000 From 181708e76e0fd390edea5eaf052cc201102ed3cf Mon Sep 17 00:00:00 2001 From: Murat Kaan Meral Date: Wed, 25 Feb 2026 15:02:51 -0500 Subject: [PATCH 2/4] fix: remove unrelated file --- pr-description.md | 80 ----------------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 pr-description.md diff --git a/pr-description.md b/pr-description.md deleted file mode 100644 index 29fca03e8..000000000 --- a/pr-description.md +++ /dev/null @@ -1,80 +0,0 @@ -## Description - -### Motivation - -See design https://github.com/strands-agents/docs/pull/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 `SkillsPlugin` class and `Skill` dataclass, both exported from the top-level `strands` package: - -```python -from strands import Agent, Skill -from strands.plugins.skills import SkillsPlugin - -# Load skills from filesystem (individual dirs or parent dirs) -plugin = SkillsPlugin(skills=["./skills/pdf-processing", "./skills/"]) - -# Or provide Skill instances directly -skill = Skill(name="my-skill", description="A custom skill", instructions="Do the thing") -plugin = SkillsPlugin(skills=[skill]) - -agent = Agent(plugins=[plugin]) -``` - -The plugin registers a `skills` tool 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. - -Skill metadata is injected into the system prompt as XML before each invocation, following the recommended format from the integration spec: - -```xml - - - pdf-processing - Extract text and tables from PDF files. - /path/to/pdf-processing/SKILL.md - - -``` - -The active skill selection is persisted to `agent.state` for session recovery. - -### Use Cases - -- **Skill libraries**: Point the plugin at a directory of skills and let the agent pick the right one based on the user's request -- **Dynamic specialization**: Swap agent behavior at runtime without rebuilding prompts or agents -- **Portable skills**: Share skill directories across teams and agents using the Agent Skills standard format - -## Related Issues - -https://github.com/strands-agents/sdk-python/issues/1181 - -## Documentation PR - -TBD - -## Type of Change - -New feature - -## Testing - -- Manually tested using jupyter notebook and set of skills from `anthropic/skills` repository -- 56 unit tests covering the plugin, tool, XML generation, 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 plugin tests (136 total) continue to pass - -- [x] I ran `hatch run prepare` - -## Checklist -- [x] I have read the CONTRIBUTING document -- [x] I have added any necessary tests that prove my fix is effective or my feature works -- [ ] I have updated the documentation accordingly -- [ ] I have added an appropriate example to the documentation to outline the feature, or no new docs are needed -- [x] My changes generate no new warnings -- [ ] Any dependent changes have been merged and published - ----- - -By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. From f8601d30274bfb9b2c070923011c6aee3045fbd4 Mon Sep 17 00:00:00 2001 From: Murat Kaan Meral Date: Wed, 25 Feb 2026 15:05:52 -0500 Subject: [PATCH 3/4] fix: remove comment --- src/strands/agent/agent.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 74bac3616..c7a2ec7e7 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -768,10 +768,6 @@ async def _run_loop( ) -> AsyncGenerator[TypedEvent, None]: """Execute the agent's event loop with the given message and parameters. - Supports automatic resume: if a hook sets ``resume`` on the AfterInvocationEvent - to a non-None agent input, the loop re-invokes with that input, triggering a full - new invocation cycle including BeforeInvocationEvent. - Args: messages: The input messages to add to the conversation. invocation_state: Additional parameters to pass to the event loop. From ae38264d1c48d04739a48b54d725dd4433b0aa20 Mon Sep 17 00:00:00 2001 From: Murat Kaan Meral Date: Wed, 25 Feb 2026 15:07:16 -0500 Subject: [PATCH 4/4] fix: cleanup --- workspace/strands-sdk-python | 1 - 1 file changed, 1 deletion(-) delete mode 160000 workspace/strands-sdk-python diff --git a/workspace/strands-sdk-python b/workspace/strands-sdk-python deleted file mode 160000 index c4503d1f3..000000000 --- a/workspace/strands-sdk-python +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c4503d1f37400d26a87947d006b39d114ed7c000