Skip to content

Commit 45499a7

Browse files
authored
Merge branch 'main' into dependabot/pip/pytest-cov-gte-4.1.0-and-lt-7.0.0
2 parents f8e5a39 + 0283169 commit 45499a7

File tree

26 files changed

+1224
-1104
lines changed

26 files changed

+1224
-1104
lines changed

.github/workflows/integration-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
aws-region: us-east-1
5353
mask-aws-account-id: true
5454
- name: Checkout head commit
55-
uses: actions/checkout@v4
55+
uses: actions/checkout@v5
5656
with:
5757
ref: ${{ github.event.pull_request.head.sha }} # Pull the commit from the forked repo
5858
persist-credentials: false # Don't persist credentials for subsequent actions

.github/workflows/pypi-publish-on-release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
runs-on: ubuntu-latest
2323

2424
steps:
25-
- uses: actions/checkout@v4
25+
- uses: actions/checkout@v5
2626
with:
2727
persist-credentials: false
2828

@@ -74,7 +74,7 @@ jobs:
7474

7575
steps:
7676
- name: Download all the dists
77-
uses: actions/download-artifact@v4
77+
uses: actions/download-artifact@v5
7878
with:
7979
name: python-package-distributions
8080
path: dist/

.github/workflows/test-lint.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
LOG_LEVEL: DEBUG
5252
steps:
5353
- name: Checkout code
54-
uses: actions/checkout@v4
54+
uses: actions/checkout@v5
5555
with:
5656
ref: ${{ inputs.ref }} # Explicitly define which commit to check out
5757
persist-credentials: false # Don't persist credentials for subsequent actions
@@ -73,7 +73,7 @@ jobs:
7373
contents: read
7474
steps:
7575
- name: Checkout code
76-
uses: actions/checkout@v4
76+
uses: actions/checkout@v5
7777
with:
7878
ref: ${{ inputs.ref }}
7979
persist-credentials: false

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ dependencies = [
3030
"botocore>=1.29.0,<2.0.0",
3131
"docstring_parser>=0.15,<1.0",
3232
"mcp>=1.11.0,<2.0.0",
33-
"pydantic>=2.0.0,<3.0.0",
33+
"pydantic>=2.4.0,<3.0.0",
3434
"typing-extensions>=4.13.2,<5.0.0",
3535
"watchdog>=6.0.0,<7.0.0",
3636
"opentelemetry-api>=1.30.0,<2.0.0",
@@ -55,12 +55,12 @@ dev = [
5555
"hatch>=1.0.0,<2.0.0",
5656
"moto>=5.1.0,<6.0.0",
5757
"mypy>=1.15.0,<2.0.0",
58-
"pre-commit>=3.2.0,<4.2.0",
58+
"pre-commit>=3.2.0,<4.4.0",
5959
"pytest>=8.0.0,<9.0.0",
60-
"pytest-asyncio>=0.26.0,<0.27.0",
6160
"pytest-cov>=6.0.0,<7.0.0",
61+
"pytest-asyncio>=1.0.0,<1.2.0",
6262
"pytest-xdist>=3.0.0,<4.0.0",
63-
"ruff>=0.4.4,<0.5.0",
63+
"ruff>=0.12.0,<0.13.0",
6464
]
6565
docs = [
6666
"sphinx>=5.0.0,<6.0.0",
@@ -143,8 +143,8 @@ features = ["anthropic", "litellm", "llamaapi", "ollama", "openai", "otel", "mis
143143
extra-dependencies = [
144144
"moto>=5.1.0,<6.0.0",
145145
"pytest>=8.0.0,<9.0.0",
146-
"pytest-asyncio>=0.26.0,<0.27.0",
147146
"pytest-cov>=6.0.0,<7.0.0",
147+
"pytest-asyncio>=1.0.0,<1.2.0",
148148
"pytest-xdist>=3.0.0,<4.0.0",
149149
]
150150
extra-args = [

src/strands/agent/agent.py

Lines changed: 105 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pydantic import BaseModel
2121

2222
from .. import _identifier
23-
from ..event_loop.event_loop import event_loop_cycle, run_tool
23+
from ..event_loop.event_loop import event_loop_cycle
2424
from ..handlers.callback_handler import PrintingCallbackHandler, null_callback_handler
2525
from ..hooks import (
2626
AfterInvocationEvent,
@@ -35,6 +35,8 @@
3535
from ..session.session_manager import SessionManager
3636
from ..telemetry.metrics import EventLoopMetrics
3737
from ..telemetry.tracer import get_tracer, serialize
38+
from ..tools.executors import ConcurrentToolExecutor
39+
from ..tools.executors._executor import ToolExecutor
3840
from ..tools.registry import ToolRegistry
3941
from ..tools.watcher import ToolWatcher
4042
from ..types.content import ContentBlock, Message, Messages
@@ -136,13 +138,14 @@ def caller(
136138
"name": normalized_name,
137139
"input": kwargs.copy(),
138140
}
141+
tool_results: list[ToolResult] = []
142+
invocation_state = kwargs
139143

140144
async def acall() -> ToolResult:
141-
# Pass kwargs as invocation_state
142-
async for event in run_tool(self._agent, tool_use, kwargs):
145+
async for event in ToolExecutor._stream(self._agent, tool_use, tool_results, invocation_state):
143146
_ = event
144147

145-
return cast(ToolResult, event)
148+
return tool_results[0]
146149

147150
def tcall() -> ToolResult:
148151
return asyncio.run(acall())
@@ -208,6 +211,7 @@ def __init__(
208211
state: Optional[Union[AgentState, dict]] = None,
209212
hooks: Optional[list[HookProvider]] = None,
210213
session_manager: Optional[SessionManager] = None,
214+
tool_executor: Optional[ToolExecutor] = None,
211215
):
212216
"""Initialize the Agent with the specified configuration.
213217
@@ -250,6 +254,7 @@ def __init__(
250254
Defaults to None.
251255
session_manager: Manager for handling agent sessions including conversation history and state.
252256
If provided, enables session-based persistence and state management.
257+
tool_executor: Definition of tool execution stragety (e.g., sequential, concurrent, etc.).
253258
254259
Raises:
255260
ValueError: If agent id contains path separators.
@@ -324,6 +329,8 @@ def __init__(
324329
if self._session_manager:
325330
self.hooks.add_hook(self._session_manager)
326331

332+
self.tool_executor = tool_executor or ConcurrentToolExecutor()
333+
327334
if hooks:
328335
for hook in hooks:
329336
self.hooks.add_hook(hook)
@@ -354,14 +361,21 @@ def tool_names(self) -> list[str]:
354361
all_tools = self.tool_registry.get_all_tools_config()
355362
return list(all_tools.keys())
356363

357-
def __call__(self, prompt: Union[str, list[ContentBlock]], **kwargs: Any) -> AgentResult:
364+
def __call__(self, prompt: str | list[ContentBlock] | Messages | None = None, **kwargs: Any) -> AgentResult:
358365
"""Process a natural language prompt through the agent's event loop.
359366
360-
This method implements the conversational interface (e.g., `agent("hello!")`). It adds the user's prompt to
361-
the conversation history, processes it through the model, executes any tool calls, and returns the final result.
367+
This method implements the conversational interface with multiple input patterns:
368+
- String input: `agent("hello!")`
369+
- ContentBlock list: `agent([{"text": "hello"}, {"image": {...}}])`
370+
- Message list: `agent([{"role": "user", "content": [{"text": "hello"}]}])`
371+
- No input: `agent()` - uses existing conversation history
362372
363373
Args:
364-
prompt: User input as text or list of ContentBlock objects for multi-modal content.
374+
prompt: User input in various formats:
375+
- str: Simple text input
376+
- list[ContentBlock]: Multi-modal content blocks
377+
- list[Message]: Complete messages with roles
378+
- None: Use existing conversation history
365379
**kwargs: Additional parameters to pass through the event loop.
366380
367381
Returns:
@@ -380,14 +394,23 @@ def execute() -> AgentResult:
380394
future = executor.submit(execute)
381395
return future.result()
382396

383-
async def invoke_async(self, prompt: Union[str, list[ContentBlock]], **kwargs: Any) -> AgentResult:
397+
async def invoke_async(
398+
self, prompt: str | list[ContentBlock] | Messages | None = None, **kwargs: Any
399+
) -> AgentResult:
384400
"""Process a natural language prompt through the agent's event loop.
385401
386-
This method implements the conversational interface (e.g., `agent("hello!")`). It adds the user's prompt to
387-
the conversation history, processes it through the model, executes any tool calls, and returns the final result.
402+
This method implements the conversational interface with multiple input patterns:
403+
- String input: Simple text input
404+
- ContentBlock list: Multi-modal content blocks
405+
- Message list: Complete messages with roles
406+
- No input: Use existing conversation history
388407
389408
Args:
390-
prompt: User input as text or list of ContentBlock objects for multi-modal content.
409+
prompt: User input in various formats:
410+
- str: Simple text input
411+
- list[ContentBlock]: Multi-modal content blocks
412+
- list[Message]: Complete messages with roles
413+
- None: Use existing conversation history
391414
**kwargs: Additional parameters to pass through the event loop.
392415
393416
Returns:
@@ -404,7 +427,7 @@ async def invoke_async(self, prompt: Union[str, list[ContentBlock]], **kwargs: A
404427

405428
return cast(AgentResult, event["result"])
406429

407-
def structured_output(self, output_model: Type[T], prompt: Optional[Union[str, list[ContentBlock]]] = None) -> T:
430+
def structured_output(self, output_model: Type[T], prompt: str | list[ContentBlock] | Messages | None = None) -> T:
408431
"""This method allows you to get structured output from the agent.
409432
410433
If you pass in a prompt, it will be used temporarily without adding it to the conversation history.
@@ -416,7 +439,11 @@ def structured_output(self, output_model: Type[T], prompt: Optional[Union[str, l
416439
Args:
417440
output_model: The output model (a JSON schema written as a Pydantic BaseModel)
418441
that the agent will use when responding.
419-
prompt: The prompt to use for the agent (will not be added to conversation history).
442+
prompt: The prompt to use for the agent in various formats:
443+
- str: Simple text input
444+
- list[ContentBlock]: Multi-modal content blocks
445+
- list[Message]: Complete messages with roles
446+
- None: Use existing conversation history
420447
421448
Raises:
422449
ValueError: If no conversation history or prompt is provided.
@@ -430,7 +457,7 @@ def execute() -> T:
430457
return future.result()
431458

432459
async def structured_output_async(
433-
self, output_model: Type[T], prompt: Optional[Union[str, list[ContentBlock]]] = None
460+
self, output_model: Type[T], prompt: str | list[ContentBlock] | Messages | None = None
434461
) -> T:
435462
"""This method allows you to get structured output from the agent.
436463
@@ -455,12 +482,8 @@ async def structured_output_async(
455482
try:
456483
if not self.messages and not prompt:
457484
raise ValueError("No conversation history or prompt provided")
458-
# Create temporary messages array if prompt is provided
459-
if prompt:
460-
content: list[ContentBlock] = [{"text": prompt}] if isinstance(prompt, str) else prompt
461-
temp_messages = self.messages + [{"role": "user", "content": content}]
462-
else:
463-
temp_messages = self.messages
485+
486+
temp_messages: Messages = self.messages + self._convert_prompt_to_messages(prompt)
464487

465488
structured_output_span.set_attributes(
466489
{
@@ -470,16 +493,16 @@ async def structured_output_async(
470493
"gen_ai.operation.name": "execute_structured_output",
471494
}
472495
)
473-
for message in temp_messages:
474-
structured_output_span.add_event(
475-
f"gen_ai.{message['role']}.message",
476-
attributes={"role": message["role"], "content": serialize(message["content"])},
477-
)
478496
if self.system_prompt:
479497
structured_output_span.add_event(
480498
"gen_ai.system.message",
481499
attributes={"role": "system", "content": serialize([{"text": self.system_prompt}])},
482500
)
501+
for message in temp_messages:
502+
structured_output_span.add_event(
503+
f"gen_ai.{message['role']}.message",
504+
attributes={"role": message["role"], "content": serialize(message["content"])},
505+
)
483506
events = self.model.structured_output(output_model, temp_messages, system_prompt=self.system_prompt)
484507
async for event in events:
485508
if "callback" in event:
@@ -492,16 +515,25 @@ async def structured_output_async(
492515
finally:
493516
self.hooks.invoke_callbacks(AfterInvocationEvent(agent=self))
494517

495-
async def stream_async(self, prompt: Union[str, list[ContentBlock]], **kwargs: Any) -> AsyncIterator[Any]:
518+
async def stream_async(
519+
self,
520+
prompt: str | list[ContentBlock] | Messages | None = None,
521+
**kwargs: Any,
522+
) -> AsyncIterator[Any]:
496523
"""Process a natural language prompt and yield events as an async iterator.
497524
498-
This method provides an asynchronous interface for streaming agent events, allowing
499-
consumers to process stream events programmatically through an async iterator pattern
500-
rather than callback functions. This is particularly useful for web servers and other
501-
async environments.
525+
This method provides an asynchronous interface for streaming agent events with multiple input patterns:
526+
- String input: Simple text input
527+
- ContentBlock list: Multi-modal content blocks
528+
- Message list: Complete messages with roles
529+
- No input: Use existing conversation history
502530
503531
Args:
504-
prompt: User input as text or list of ContentBlock objects for multi-modal content.
532+
prompt: User input in various formats:
533+
- str: Simple text input
534+
- list[ContentBlock]: Multi-modal content blocks
535+
- list[Message]: Complete messages with roles
536+
- None: Use existing conversation history
505537
**kwargs: Additional parameters to pass to the event loop.
506538
507539
Yields:
@@ -525,13 +557,15 @@ async def stream_async(self, prompt: Union[str, list[ContentBlock]], **kwargs: A
525557
"""
526558
callback_handler = kwargs.get("callback_handler", self.callback_handler)
527559

528-
content: list[ContentBlock] = [{"text": prompt}] if isinstance(prompt, str) else prompt
529-
message: Message = {"role": "user", "content": content}
560+
# Process input and get message to add (if any)
561+
messages = self._convert_prompt_to_messages(prompt)
562+
563+
self.trace_span = self._start_agent_trace_span(messages)
530564

531-
self.trace_span = self._start_agent_trace_span(message)
532565
with trace_api.use_span(self.trace_span):
533566
try:
534-
events = self._run_loop(message, invocation_state=kwargs)
567+
events = self._run_loop(messages, invocation_state=kwargs)
568+
535569
async for event in events:
536570
if "callback" in event:
537571
callback_handler(**event["callback"])
@@ -548,12 +582,12 @@ async def stream_async(self, prompt: Union[str, list[ContentBlock]], **kwargs: A
548582
raise
549583

550584
async def _run_loop(
551-
self, message: Message, invocation_state: dict[str, Any]
585+
self, messages: Messages, invocation_state: dict[str, Any]
552586
) -> AsyncGenerator[dict[str, Any], None]:
553587
"""Execute the agent's event loop with the given message and parameters.
554588
555589
Args:
556-
message: The user message to add to the conversation.
590+
messages: The input messages to add to the conversation.
557591
invocation_state: Additional parameters to pass to the event loop.
558592
559593
Yields:
@@ -564,7 +598,8 @@ async def _run_loop(
564598
try:
565599
yield {"callback": {"init_event_loop": True, **invocation_state}}
566600

567-
self._append_message(message)
601+
for message in messages:
602+
self._append_message(message)
568603

569604
# Execute the event loop cycle with retry logic for context limits
570605
events = self._execute_event_loop_cycle(invocation_state)
@@ -622,6 +657,34 @@ async def _execute_event_loop_cycle(self, invocation_state: dict[str, Any]) -> A
622657
async for event in events:
623658
yield event
624659

660+
def _convert_prompt_to_messages(self, prompt: str | list[ContentBlock] | Messages | None) -> Messages:
661+
messages: Messages | None = None
662+
if prompt is not None:
663+
if isinstance(prompt, str):
664+
# String input - convert to user message
665+
messages = [{"role": "user", "content": [{"text": prompt}]}]
666+
elif isinstance(prompt, list):
667+
if len(prompt) == 0:
668+
# Empty list
669+
messages = []
670+
# Check if all item in input list are dictionaries
671+
elif all(isinstance(item, dict) for item in prompt):
672+
# Check if all items are messages
673+
if all(all(key in item for key in Message.__annotations__.keys()) for item in prompt):
674+
# Messages input - add all messages to conversation
675+
messages = cast(Messages, prompt)
676+
677+
# Check if all items are content blocks
678+
elif all(any(key in ContentBlock.__annotations__.keys() for key in item) for item in prompt):
679+
# Treat as List[ContentBlock] input - convert to user message
680+
# This allows invalid structures to be passed through to the model
681+
messages = [{"role": "user", "content": cast(list[ContentBlock], prompt)}]
682+
else:
683+
messages = []
684+
if messages is None:
685+
raise ValueError("Input prompt must be of type: `str | list[Contentblock] | Messages | None`.")
686+
return messages
687+
625688
def _record_tool_execution(
626689
self,
627690
tool: ToolUse,
@@ -687,15 +750,15 @@ def _record_tool_execution(
687750
self._append_message(tool_result_msg)
688751
self._append_message(assistant_msg)
689752

690-
def _start_agent_trace_span(self, message: Message) -> trace_api.Span:
753+
def _start_agent_trace_span(self, messages: Messages) -> trace_api.Span:
691754
"""Starts a trace span for the agent.
692755
693756
Args:
694-
message: The user message.
757+
messages: The input messages.
695758
"""
696759
model_id = self.model.config.get("model_id") if hasattr(self.model, "config") else None
697760
return self.tracer.start_agent_span(
698-
message=message,
761+
messages=messages,
699762
agent_name=self.name,
700763
model_id=model_id,
701764
tools=self.tool_names,

0 commit comments

Comments
 (0)