Skip to content

Commit c5ce36f

Browse files
committed
Merge branch 'main' of https://github.com/strands-agents/sdk-python into tool-validation
2 parents 40bd6ff + 26862e4 commit c5ce36f

File tree

83 files changed

+4203
-408
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

83 files changed

+4203
-408
lines changed

.github/workflows/test-lint.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ jobs:
6666
id: tests
6767
run: hatch test tests --cover
6868
continue-on-error: false
69+
70+
- name: Upload coverage reports to Codecov
71+
uses: codecov/codecov-action@v5
72+
with:
73+
token: ${{ secrets.CODECOV_TOKEN }}
6974
lint:
7075
name: Lint
7176
runs-on: ubuntu-latest

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ Before starting work on any issue:
3636
3. Wait for maintainer confirmation before beginning significant work
3737

3838

39+
## Development Tenets
40+
Our team follows these core principles when designing and implementing features. These tenets help us make consistent decisions, resolve trade-offs, and maintain the quality and coherence of the SDK. When contributing, please consider how your changes align with these principles:
41+
42+
1. **Simple at any scale:** We believe that simple things should be simple. The same clean abstractions that power a weekend prototype should scale effortlessly to production workloads. We reject the notion that enterprise-grade means enterprise-complicated - Strands remains approachable whether it's your first agent or your millionth.
43+
2. **Extensible by design:** We allow for as much configuration as possible, from hooks to model providers, session managers, tools, etc. We meet customers where they are with flexible extension points that are simple to integrate with.
44+
3. **Composability:** Primitives are building blocks with each other. Each feature of Strands is developed with all other features in mind, they are consistent and complement one another.
45+
4. **The obvious path is the happy path:** Through intuitive naming, helpful error messages, and thoughtful API design, we guide developers toward correct patterns and away from common pitfalls.
46+
5. **We are accessible to humans and agents:** Strands is designed for both humans and AI to understand equally well. We don’t take shortcuts on curated DX for humans and we go the extra mile to make sure coding assistants can help you use those interfaces the right way.
47+
6. **Embrace common standards:** We respect what came before, and do not want to reinvent something that is already widely adopted or done better.
48+
49+
When proposing solutions or reviewing code, we reference these principles to guide our decisions. If two approaches seem equally valid, we choose the one that best aligns with our tenets.
50+
3951
## Development Environment
4052

4153
This project uses [hatchling](https://hatch.pypa.io/latest/build/#hatchling) as the build backend and [hatch](https://hatch.pypa.io/latest/) for development workflow management.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ agent("Tell me about Agentic AI")
143143

144144
# Google Gemini
145145
gemini_model = GeminiModel(
146-
api_key="your_gemini_api_key",
146+
client_args={
147+
"api_key": "your_gemini_api_key",
148+
},
147149
model_id="gemini-2.5-flash",
148150
params={"temperature": 0.7}
149151
)

src/strands/_exception_notes.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Exception note utilities for Python 3.10+ compatibility."""
2+
3+
# add_note was added in 3.11 - we hoist to a constant to facilitate testing
4+
supports_add_note = hasattr(Exception, "add_note")
5+
6+
7+
def add_exception_note(exception: Exception, note: str) -> None:
8+
"""Add a note to an exception, compatible with Python 3.10+.
9+
10+
Uses add_note() if it's available (Python 3.11+) or modifies the exception message if it is not.
11+
"""
12+
if supports_add_note:
13+
# we ignore the mypy error because the version-check for add_note is extracted into a constant up above and
14+
# mypy doesn't detect that
15+
exception.add_note(note) # type: ignore
16+
else:
17+
# For Python 3.10, append note to the exception message
18+
if hasattr(exception, "args") and exception.args:
19+
exception.args = (f"{exception.args[0]}\n{note}",) + exception.args[1:]
20+
else:
21+
exception.args = (note,)

src/strands/agent/agent.py

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import json
1414
import logging
1515
import random
16+
import warnings
1617
from concurrent.futures import ThreadPoolExecutor
1718
from typing import (
1819
Any,
@@ -54,13 +55,15 @@
5455
from ..types.agent import AgentInput
5556
from ..types.content import ContentBlock, Message, Messages
5657
from ..types.exceptions import ContextWindowOverflowException
58+
from ..types.interrupt import InterruptResponseContent
5759
from ..types.tools import ToolResult, ToolUse
5860
from ..types.traces import AttributeValue
5961
from .agent_result import AgentResult
6062
from .conversation_manager import (
6163
ConversationManager,
6264
SlidingWindowConversationManager,
6365
)
66+
from .interrupt import InterruptState
6467
from .state import AgentState
6568

6669
logger = logging.getLogger(__name__)
@@ -142,6 +145,9 @@ def caller(
142145
Raises:
143146
AttributeError: If the tool doesn't exist.
144147
"""
148+
if self._agent._interrupt_state.activated:
149+
raise RuntimeError("cannot directly call tool during interrupt")
150+
145151
normalized_name = self._find_normalized_tool_name(name)
146152

147153
# Create unique tool ID and set up the tool request
@@ -337,6 +343,8 @@ def __init__(
337343

338344
self.hooks = HookRegistry()
339345

346+
self._interrupt_state = InterruptState()
347+
340348
# Initialize session management functionality
341349
self._session_manager = session_manager
342350
if self._session_manager:
@@ -374,7 +382,9 @@ def tool_names(self) -> list[str]:
374382
all_tools = self.tool_registry.get_all_tools_config()
375383
return list(all_tools.keys())
376384

377-
def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
385+
def __call__(
386+
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
387+
) -> AgentResult:
378388
"""Process a natural language prompt through the agent's event loop.
379389
380390
This method implements the conversational interface with multiple input patterns:
@@ -389,7 +399,8 @@ def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
389399
- list[ContentBlock]: Multi-modal content blocks
390400
- list[Message]: Complete messages with roles
391401
- None: Use existing conversation history
392-
**kwargs: Additional parameters to pass through the event loop.
402+
invocation_state: Additional parameters to pass through the event loop.
403+
**kwargs: Additional parameters to pass through the event loop.[Deprecating]
393404
394405
Returns:
395406
Result object containing:
@@ -401,13 +412,15 @@ def __call__(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
401412
"""
402413

403414
def execute() -> AgentResult:
404-
return asyncio.run(self.invoke_async(prompt, **kwargs))
415+
return asyncio.run(self.invoke_async(prompt, invocation_state=invocation_state, **kwargs))
405416

406417
with ThreadPoolExecutor() as executor:
407418
future = executor.submit(execute)
408419
return future.result()
409420

410-
async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentResult:
421+
async def invoke_async(
422+
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
423+
) -> AgentResult:
411424
"""Process a natural language prompt through the agent's event loop.
412425
413426
This method implements the conversational interface with multiple input patterns:
@@ -422,7 +435,8 @@ async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentR
422435
- list[ContentBlock]: Multi-modal content blocks
423436
- list[Message]: Complete messages with roles
424437
- None: Use existing conversation history
425-
**kwargs: Additional parameters to pass through the event loop.
438+
invocation_state: Additional parameters to pass through the event loop.
439+
**kwargs: Additional parameters to pass through the event loop.[Deprecating]
426440
427441
Returns:
428442
Result: object containing:
@@ -432,7 +446,7 @@ async def invoke_async(self, prompt: AgentInput = None, **kwargs: Any) -> AgentR
432446
- metrics: Performance metrics from the event loop
433447
- state: The final state of the event loop
434448
"""
435-
events = self.stream_async(prompt, **kwargs)
449+
events = self.stream_async(prompt, invocation_state=invocation_state, **kwargs)
436450
async for event in events:
437451
_ = event
438452

@@ -484,6 +498,9 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
484498
Raises:
485499
ValueError: If no conversation history or prompt is provided.
486500
"""
501+
if self._interrupt_state.activated:
502+
raise RuntimeError("cannot call structured output during interrupt")
503+
487504
self.hooks.invoke_callbacks(BeforeInvocationEvent(agent=self))
488505
with self.tracer.tracer.start_as_current_span(
489506
"execute_structured_output", kind=trace_api.SpanKind.CLIENT
@@ -528,9 +545,7 @@ async def structured_output_async(self, output_model: Type[T], prompt: AgentInpu
528545
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))
529546

530547
async def stream_async(
531-
self,
532-
prompt: AgentInput = None,
533-
**kwargs: Any,
548+
self, prompt: AgentInput = None, *, invocation_state: dict[str, Any] | None = None, **kwargs: Any
534549
) -> AsyncIterator[Any]:
535550
"""Process a natural language prompt and yield events as an async iterator.
536551
@@ -546,7 +561,8 @@ async def stream_async(
546561
- list[ContentBlock]: Multi-modal content blocks
547562
- list[Message]: Complete messages with roles
548563
- None: Use existing conversation history
549-
**kwargs: Additional parameters to pass to the event loop.
564+
invocation_state: Additional parameters to pass through the event loop.
565+
**kwargs: Additional parameters to pass to the event loop.[Deprecating]
550566
551567
Yields:
552568
An async iterator that yields events. Each event is a dictionary containing
@@ -567,7 +583,21 @@ async def stream_async(
567583
yield event["data"]
568584
```
569585
"""
570-
callback_handler = kwargs.get("callback_handler", self.callback_handler)
586+
self._resume_interrupt(prompt)
587+
588+
merged_state = {}
589+
if kwargs:
590+
warnings.warn("`**kwargs` parameter is deprecating, use `invocation_state` instead.", stacklevel=2)
591+
merged_state.update(kwargs)
592+
if invocation_state is not None:
593+
merged_state["invocation_state"] = invocation_state
594+
else:
595+
if invocation_state is not None:
596+
merged_state = invocation_state
597+
598+
callback_handler = self.callback_handler
599+
if kwargs:
600+
callback_handler = kwargs.get("callback_handler", self.callback_handler)
571601

572602
# Process input and get message to add (if any)
573603
messages = self._convert_prompt_to_messages(prompt)
@@ -576,10 +606,10 @@ async def stream_async(
576606

577607
with trace_api.use_span(self.trace_span):
578608
try:
579-
events = self._run_loop(messages, invocation_state=kwargs)
609+
events = self._run_loop(messages, invocation_state=merged_state)
580610

581611
async for event in events:
582-
event.prepare(invocation_state=kwargs)
612+
event.prepare(invocation_state=merged_state)
583613

584614
if event.is_callback_event:
585615
as_dict = event.as_dict()
@@ -596,6 +626,38 @@ async def stream_async(
596626
self._end_agent_trace_span(error=e)
597627
raise
598628

629+
def _resume_interrupt(self, prompt: AgentInput) -> None:
630+
"""Configure the interrupt state if resuming from an interrupt event.
631+
632+
Args:
633+
prompt: User responses if resuming from interrupt.
634+
635+
Raises:
636+
TypeError: If in interrupt state but user did not provide responses.
637+
"""
638+
if not self._interrupt_state.activated:
639+
return
640+
641+
if not isinstance(prompt, list):
642+
raise TypeError(f"prompt_type={type(prompt)} | must resume from interrupt with list of interruptResponse's")
643+
644+
invalid_types = [
645+
content_type for content in prompt for content_type in content if content_type != "interruptResponse"
646+
]
647+
if invalid_types:
648+
raise TypeError(
649+
f"content_types=<{invalid_types}> | must resume from interrupt with list of interruptResponse's"
650+
)
651+
652+
for content in cast(list[InterruptResponseContent], prompt):
653+
interrupt_id = content["interruptResponse"]["interruptId"]
654+
interrupt_response = content["interruptResponse"]["response"]
655+
656+
if interrupt_id not in self._interrupt_state.interrupts:
657+
raise KeyError(f"interrupt_id=<{interrupt_id}> | no interrupt found")
658+
659+
self._interrupt_state.interrupts[interrupt_id].response = interrupt_response
660+
599661
async def _run_loop(self, messages: Messages, invocation_state: dict[str, Any]) -> AsyncGenerator[TypedEvent, None]:
600662
"""Execute the agent's event loop with the given message and parameters.
601663
@@ -671,6 +733,9 @@ async def _execute_event_loop_cycle(self, invocation_state: dict[str, Any]) -> A
671733
yield event
672734

673735
def _convert_prompt_to_messages(self, prompt: AgentInput) -> Messages:
736+
if self._interrupt_state.activated:
737+
return []
738+
674739
messages: Messages | None = None
675740
if prompt is not None:
676741
if isinstance(prompt, str):

src/strands/agent/agent_result.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"""
55

66
from dataclasses import dataclass
7-
from typing import Any
7+
from typing import Any, Sequence
88

9+
from ..interrupt import Interrupt
910
from ..telemetry.metrics import EventLoopMetrics
1011
from ..types.content import Message
1112
from ..types.streaming import StopReason
@@ -20,12 +21,14 @@ class AgentResult:
2021
message: The last message generated by the agent.
2122
metrics: Performance metrics collected during processing.
2223
state: Additional state information from the event loop.
24+
interrupts: List of interrupts if raised by user.
2325
"""
2426

2527
stop_reason: StopReason
2628
message: Message
2729
metrics: EventLoopMetrics
2830
state: Any
31+
interrupts: Sequence[Interrupt] | None = None
2932

3033
def __str__(self) -> str:
3134
"""Get the agent's last message as a string.

src/strands/agent/conversation_manager/summarizing_conversation_manager.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from typing_extensions import override
77

8+
from ...tools import tool
9+
from ...tools.registry import ToolRegistry
810
from ...types.content import Message
911
from ...types.exceptions import ContextWindowOverflowException
1012
from .conversation_manager import ConversationManager
@@ -23,6 +25,10 @@
2325
- You MUST create a structured and concise summary in bullet-point format.
2426
- You MUST NOT respond conversationally.
2527
- You MUST NOT address the user directly.
28+
- You MUST NOT comment on tool availability.
29+
30+
Assumptions:
31+
- You MUST NOT assume tool executions failed unless otherwise stated.
2632
2733
Task:
2834
Your task is to create a structured summary document:
@@ -182,9 +188,10 @@ def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message:
182188
# Choose which agent to use for summarization
183189
summarization_agent = self.summarization_agent if self.summarization_agent is not None else agent
184190

185-
# Save original system prompt and messages to restore later
191+
# Save original system prompt, messages, and tool registry to restore later
186192
original_system_prompt = summarization_agent.system_prompt
187193
original_messages = summarization_agent.messages.copy()
194+
original_tool_registry = summarization_agent.tool_registry
188195

189196
try:
190197
# Only override system prompt if no agent was provided during initialization
@@ -197,6 +204,13 @@ def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message:
197204
)
198205
# Temporarily set the system prompt for summarization
199206
summarization_agent.system_prompt = system_prompt
207+
208+
# Add no-op tool if agent has no tools to satisfy tool spec requirement
209+
if not summarization_agent.tool_names:
210+
tool_registry = ToolRegistry()
211+
tool_registry.register_tool(self._noop_tool)
212+
summarization_agent.tool_registry = tool_registry
213+
200214
summarization_agent.messages = messages
201215

202216
# Use the agent to generate summary with rich content (can use tools if needed)
@@ -207,6 +221,7 @@ def _generate_summary(self, messages: List[Message], agent: "Agent") -> Message:
207221
# Restore original agent state
208222
summarization_agent.system_prompt = original_system_prompt
209223
summarization_agent.messages = original_messages
224+
summarization_agent.tool_registry = original_tool_registry
210225

211226
def _adjust_split_point_for_tool_pairs(self, messages: List[Message], split_point: int) -> int:
212227
"""Adjust the split point to avoid breaking ToolUse/ToolResult pairs.
@@ -249,3 +264,13 @@ def _adjust_split_point_for_tool_pairs(self, messages: List[Message], split_poin
249264
raise ContextWindowOverflowException("Unable to trim conversation context!")
250265

251266
return split_point
267+
268+
@tool(name="noop", description="MUST NOT call or summarize")
269+
def _noop_tool(self) -> None:
270+
"""No-op tool to satisfy tool spec requirement when tool messages are present.
271+
272+
Some model provides (e.g., Bedrock) will return an error response if tool uses and tool results are present in
273+
messages without any tool specs configured. Consequently, if the summarization agent has no registered tools,
274+
summarization will fail. As a workaround, we register the no-op tool.
275+
"""
276+
pass

0 commit comments

Comments
 (0)