From 03a0fd090f4f1e07bdd1a6773e43e27f2ae2402f Mon Sep 17 00:00:00 2001 From: LondheShubham153 Date: Mon, 2 Feb 2026 21:11:20 +0530 Subject: [PATCH 1/2] Add Strands Agents SDK recipe with AWS Bedrock --- agents/strands_agents_sdk_python/README.md | 227 ++++++++++++++++++ .../activities/__init__.py | 0 .../activities/strands_agent.py | 47 ++++ .../activities/tool_activities.py | 26 ++ .../helpers/__init__.py | 0 .../helpers/prompts.py | 18 ++ .../models/__init__.py | 0 .../models/orchestrator.py | 13 + .../models/requests.py | 11 + .../strands_agents_sdk_python/pyproject.toml | 18 ++ .../start_workflow.py | 48 ++++ agents/strands_agents_sdk_python/worker.py | 36 +++ .../workflows/__init__.py | 0 .../workflows/agent.py | 52 ++++ 14 files changed, 496 insertions(+) create mode 100644 agents/strands_agents_sdk_python/README.md create mode 100644 agents/strands_agents_sdk_python/activities/__init__.py create mode 100644 agents/strands_agents_sdk_python/activities/strands_agent.py create mode 100644 agents/strands_agents_sdk_python/activities/tool_activities.py create mode 100644 agents/strands_agents_sdk_python/helpers/__init__.py create mode 100644 agents/strands_agents_sdk_python/helpers/prompts.py create mode 100644 agents/strands_agents_sdk_python/models/__init__.py create mode 100644 agents/strands_agents_sdk_python/models/orchestrator.py create mode 100644 agents/strands_agents_sdk_python/models/requests.py create mode 100644 agents/strands_agents_sdk_python/pyproject.toml create mode 100644 agents/strands_agents_sdk_python/start_workflow.py create mode 100644 agents/strands_agents_sdk_python/worker.py create mode 100644 agents/strands_agents_sdk_python/workflows/__init__.py create mode 100644 agents/strands_agents_sdk_python/workflows/agent.py diff --git a/agents/strands_agents_sdk_python/README.md b/agents/strands_agents_sdk_python/README.md new file mode 100644 index 00000000..33c0da52 --- /dev/null +++ b/agents/strands_agents_sdk_python/README.md @@ -0,0 +1,227 @@ + + +# Durable Agent with Strands Agents SDK and AWS Bedrock + +This recipe demonstrates how to build a durable AI agent using the [Strands Agents SDK](https://strandsagents.com/) with AWS Bedrock's Claude models. The agent uses an **agentic loop pattern** where the LLM can iteratively call tools and use their results to formulate a final answer. + +Key patterns: + +- **Agentic loop**: LLM decides to call tools or return final answer, sees tool results, repeats until done +- **Tools as Activities**: Each tool is a Temporal Activity with its own retry/timeout configuration +- **Durable execution**: Temporal manages state and reliability for long-running agent operations + +## Prerequisites + +1. **AWS Bedrock access**: Request access to Claude Sonnet 4 in the [Bedrock console](https://console.aws.amazon.com/bedrock/) +2. **AWS credentials**: Run `aws configure` or set `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` +3. **Dependencies**: `pip install temporalio strands-agents strands-agents-tools boto3 requests` + +## Create the Activities + +*File: activities/tool_activities.py* + +```python +from datetime import datetime +import os +from temporalio import activity +import requests +from models.requests import WeatherRequest + +@activity.defn +async def get_time_activity() -> str: + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + +@activity.defn +async def get_weather_activity(request: WeatherRequest) -> str: + response = requests.get(f"https://wttr.in/{request.city}?format=%C+%t", timeout=10) + return f"{request.city}: {response.text.strip()}" + +@activity.defn +async def list_files_activity() -> str: + files = [f for f in os.listdir('.') if f.endswith('.py')] + return f"Python files: {', '.join(files[:5])}" +``` + +*File: activities/strands_agent.py* + +```python +import json +import re +from temporalio import activity +from strands import Agent +from strands.models.bedrock import BedrockModel, BotocoreConfig +from models.requests import AgentRequest +from models.orchestrator import AgentResponse +from helpers.prompts import AGENT_SYSTEM_PROMPT + +def extract_json(text: str) -> dict: + """Extract JSON from text that may contain extra content.""" + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + pass + json_match = re.search(r'\{[\s\S]*\}', text) + if json_match: + try: + return json.loads(json_match.group()) + except json.JSONDecodeError: + pass + raise ValueError("No valid JSON found in response") + +@activity.defn +async def agent_activity(request: AgentRequest) -> AgentResponse: + # Disable retries in Strands - Temporal handles retries + config = BotocoreConfig(retries={'max_attempts': 0}) + model = BedrockModel(model_id=request.model_id, config=config) + agent = Agent(model=model, system_prompt=AGENT_SYSTEM_PROMPT) + + conversation = "\n\n".join([ + f"{msg['role']}: {msg['content']}" for msg in request.messages + ]) + result = agent(conversation) + result_text = result.content if hasattr(result, 'content') else str(result) + + try: + return AgentResponse(**extract_json(result_text)) + except (json.JSONDecodeError, ValueError): + return AgentResponse(tool_calls=[], final_answer=result_text, reasoning="Parsing failed") +``` + +## Create the Workflow + +Activities are called by string name to avoid importing non-deterministic code into the workflow sandbox. + +*File: workflows/agent.py* + +```python +from datetime import timedelta +from temporalio import workflow +from models.requests import AgentRequest, WeatherRequest + +@workflow.defn +class StrandsAgentWorkflow: + @workflow.run + async def run(self, user_input: str) -> str: + messages = [{"role": "user", "content": user_input}] + + for iteration in range(10): + response = await workflow.execute_activity( + "agent_activity", + AgentRequest(messages=messages), + start_to_close_timeout=timedelta(seconds=30) + ) + + if response.get("tool_calls"): + tool_results = [] + for tool_call in response["tool_calls"]: + result = await self._execute_tool(tool_call["tool_name"], tool_call.get("parameters", {})) + tool_results.append(f"{tool_call['tool_name']}: {result}") + messages.append({"role": "assistant", "content": f"Called tools: {' | '.join(tool_results)}"}) + continue + + if response.get("final_answer"): + return response["final_answer"] + + return "Agent exceeded maximum iterations" + + async def _execute_tool(self, tool_name: str, parameters: dict) -> str: + if tool_name == "get_time": + return await workflow.execute_activity("get_time_activity", start_to_close_timeout=timedelta(seconds=10)) + elif tool_name == "get_weather": + return await workflow.execute_activity("get_weather_activity", WeatherRequest(**parameters), start_to_close_timeout=timedelta(seconds=10)) + elif tool_name == "list_files": + return await workflow.execute_activity("list_files_activity", start_to_close_timeout=timedelta(seconds=10)) + return f"Unknown tool: {tool_name}" +``` + +## Create the Worker + +*File: worker.py* + +```python +import asyncio +from concurrent.futures import ThreadPoolExecutor +from temporalio.client import Client +from temporalio.worker import Worker +from temporalio.contrib.pydantic import pydantic_data_converter + +from workflows.agent import StrandsAgentWorkflow +from activities.strands_agent import agent_activity +from activities.tool_activities import get_time_activity, get_weather_activity, list_files_activity + +async def main(): + client = await Client.connect("localhost:7233", data_converter=pydantic_data_converter) + worker = Worker( + client, + task_queue="strands-agent-task-queue", + workflows=[StrandsAgentWorkflow], + activities=[agent_activity, get_time_activity, get_weather_activity, list_files_activity], + activity_executor=ThreadPoolExecutor(max_workers=10), + ) + print("Worker started, task queue: strands-agent-task-queue") + await worker.run() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Running + +Start the Temporal dev server: + +```bash +temporal server start-dev +``` + +Run the worker (set AWS credentials first): + +```bash +export AWS_REGION=us-east-1 +python worker.py +``` + +Start the client: + +```bash +python start_workflow.py +``` + +## Example Interactions + +``` +================================================================================ +Strands Agent Chat (type 'exit' or 'quit' to end) +================================================================================ + +You: What time is it? + +Agent: The current time is 2026-01-30 14:30:15. +-------------------------------------------------------------------------------- + +You: What's the weather in London? + +Agent: The weather in London is Partly cloudy with a temperature of 12°C. +-------------------------------------------------------------------------------- + +You: exit + +Goodbye! +``` + +## Troubleshooting + +**Model access error**: Request access to Claude Sonnet 4 in the [Bedrock console](https://console.aws.amazon.com/bedrock/). + +**Credentials not found**: Run `aws configure` or set environment variables. + +**Inference profile error**: Change model ID in `models/requests.py` from `us.anthropic.claude-sonnet-4-20250514-v1:0` to `anthropic.claude-sonnet-4-20250514-v1:0`. + +## Learn More + +- [Strands Agents Documentation](https://strandsagents.com/latest/documentation/) +- [AWS Bedrock User Guide](https://docs.aws.amazon.com/bedrock/latest/userguide/) +- [Temporal Python SDK](https://docs.temporal.io/dev-guide/python) \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/activities/__init__.py b/agents/strands_agents_sdk_python/activities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/strands_agents_sdk_python/activities/strands_agent.py b/agents/strands_agents_sdk_python/activities/strands_agent.py new file mode 100644 index 00000000..5d9eee68 --- /dev/null +++ b/agents/strands_agents_sdk_python/activities/strands_agent.py @@ -0,0 +1,47 @@ +import json +import re +from temporalio import activity +from strands import Agent +from strands.models.bedrock import BedrockModel, BotocoreConfig + +from models.requests import AgentRequest +from models.orchestrator import AgentResponse +from helpers.prompts import AGENT_SYSTEM_PROMPT + + +def extract_json(text: str) -> dict: + """Extract JSON from text that may contain extra content.""" + try: + return json.loads(text.strip()) + except json.JSONDecodeError: + pass + + json_match = re.search(r'\{[\s\S]*\}', text) + if json_match: + try: + return json.loads(json_match.group()) + except json.JSONDecodeError: + pass + + raise ValueError("No valid JSON found in response") + + +@activity.defn +async def agent_activity(request: AgentRequest) -> AgentResponse: + # Disable retries - Temporal handles them + config = BotocoreConfig(retries={'max_attempts': 0}) + model = BedrockModel(model_id=request.model_id, config=config) + agent = Agent(model=model, system_prompt=AGENT_SYSTEM_PROMPT) + + conversation = "\n\n".join([ + f"{msg['role']}: {msg['content']}" for msg in request.messages + ]) + + result = agent(conversation) + result_text = result.content if hasattr(result, 'content') else str(result) + + try: + return AgentResponse(**extract_json(result_text)) + except (json.JSONDecodeError, ValueError) as e: + activity.logger.error(f"Failed to parse: {e}") + return AgentResponse(tool_calls=[], final_answer=result_text, reasoning="Parsing failed") \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/activities/tool_activities.py b/agents/strands_agents_sdk_python/activities/tool_activities.py new file mode 100644 index 00000000..2edb1d10 --- /dev/null +++ b/agents/strands_agents_sdk_python/activities/tool_activities.py @@ -0,0 +1,26 @@ +from datetime import datetime +import os +from temporalio import activity +import requests + +from models.requests import WeatherRequest + + +@activity.defn +async def get_time_activity() -> str: + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +@activity.defn +async def get_weather_activity(request: WeatherRequest) -> str: + response = requests.get( + f"https://wttr.in/{request.city}?format=%C+%t", + timeout=10 + ) + return f"{request.city}: {response.text.strip()}" + + +@activity.defn +async def list_files_activity() -> str: + files = [f for f in os.listdir('.') if f.endswith('.py')] + return f"Python files: {', '.join(files[:5])}" \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/helpers/__init__.py b/agents/strands_agents_sdk_python/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/strands_agents_sdk_python/helpers/prompts.py b/agents/strands_agents_sdk_python/helpers/prompts.py new file mode 100644 index 00000000..38c84e8a --- /dev/null +++ b/agents/strands_agents_sdk_python/helpers/prompts.py @@ -0,0 +1,18 @@ +AGENT_SYSTEM_PROMPT = """You are a helpful assistant with access to tools. + +Available tools: +- get_time: Returns current timestamp (no parameters) +- get_weather: Gets weather for a city (parameters: {"city": "string"}) +- list_files: Lists Python files in directory (no parameters) + +RESPONSE FORMAT: You must respond with ONLY valid JSON, no other text. + +To call tools (first turn only): +{"tool_calls": [{"tool_name": "get_weather", "parameters": {"city": "London"}}], "reasoning": "need weather data"} + +To give final answer (after seeing "Tool results:" OR if you can answer without tools): +{"tool_calls": [], "final_answer": "your response to user", "reasoning": "have all info needed"} + +IMPORTANT: When you see "Tool results:" in the conversation, that means tools were already called. Use those results to form your final_answer. Do NOT call tools again. + +For questions you cannot answer (no relevant tool available), say so in final_answer.""" \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/models/__init__.py b/agents/strands_agents_sdk_python/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/strands_agents_sdk_python/models/orchestrator.py b/agents/strands_agents_sdk_python/models/orchestrator.py new file mode 100644 index 00000000..b9e2cd4d --- /dev/null +++ b/agents/strands_agents_sdk_python/models/orchestrator.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class ToolCall(BaseModel): + tool_name: str + parameters: dict = {} + + +class AgentResponse(BaseModel): + tool_calls: List[ToolCall] = [] + final_answer: Optional[str] = None + reasoning: Optional[str] = None diff --git a/agents/strands_agents_sdk_python/models/requests.py b/agents/strands_agents_sdk_python/models/requests.py new file mode 100644 index 00000000..20e73152 --- /dev/null +++ b/agents/strands_agents_sdk_python/models/requests.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +from typing import List, Dict, Any + + +class AgentRequest(BaseModel): + messages: List[Dict[str, Any]] + model_id: str = "us.anthropic.claude-sonnet-4-20250514-v1:0" + + +class WeatherRequest(BaseModel): + city: str \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/pyproject.toml b/agents/strands_agents_sdk_python/pyproject.toml new file mode 100644 index 00000000..54cae7ce --- /dev/null +++ b/agents/strands_agents_sdk_python/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "cookbook-strands-bedrock-agent" +version = "0.1" +description = "Durable Agent using Strands Agents SDK with AWS Bedrock" +authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }] +requires-python = ">=3.10" +readme = "README.md" +license = "MIT" +dependencies = [ + "temporalio>=1.7.0,<2", + "strands-agents>=0.1.0", + "strands-agents-tools>=0.1.0", + "boto3>=1.34.0", + "requests>=2.31.0", +] + +[tool.setuptools] +py-modules = [] # No modules to install, just dependencies \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/start_workflow.py b/agents/strands_agents_sdk_python/start_workflow.py new file mode 100644 index 00000000..df2b0987 --- /dev/null +++ b/agents/strands_agents_sdk_python/start_workflow.py @@ -0,0 +1,48 @@ +import asyncio +import uuid +from temporalio.client import Client +from temporalio.common import WorkflowIDReusePolicy +from temporalio.contrib.pydantic import pydantic_data_converter + +from workflows.agent import StrandsAgentWorkflow + + +async def main(): + client = await Client.connect( + "localhost:7233", + data_converter=pydantic_data_converter, + ) + + print("=" * 80) + print("Strands Agent Chat (type 'exit' or 'quit' to end)") + print("=" * 80) + + while True: + print() + user_input = input("You: ").strip() + + if user_input.lower() in ["exit", "quit", "q"]: + print("\nGoodbye!") + break + + if not user_input: + continue + + try: + result = await client.execute_workflow( + StrandsAgentWorkflow.run, + user_input, + id=f"strands-agent-{uuid.uuid4()}", + task_queue="strands-agent-task-queue", + id_reuse_policy=WorkflowIDReusePolicy.TERMINATE_IF_RUNNING, + ) + print(f"\nAgent: {result}") + print("-" * 80) + + except Exception as e: + print(f"\nError: {e}") + print("-" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/worker.py b/agents/strands_agents_sdk_python/worker.py new file mode 100644 index 00000000..2ab0f699 --- /dev/null +++ b/agents/strands_agents_sdk_python/worker.py @@ -0,0 +1,36 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor +from temporalio.client import Client +from temporalio.worker import Worker +from temporalio.contrib.pydantic import pydantic_data_converter + +from workflows.agent import StrandsAgentWorkflow +from activities.strands_agent import agent_activity +from activities.tool_activities import get_time_activity, get_weather_activity, list_files_activity + + +async def main(): + client = await Client.connect( + "localhost:7233", + data_converter=pydantic_data_converter, + ) + + worker = Worker( + client, + task_queue="strands-agent-task-queue", + workflows=[StrandsAgentWorkflow], + activities=[ + agent_activity, + get_time_activity, + get_weather_activity, + list_files_activity, + ], + activity_executor=ThreadPoolExecutor(max_workers=10), + ) + + print("Worker started, task queue: strands-agent-task-queue") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/workflows/__init__.py b/agents/strands_agents_sdk_python/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/strands_agents_sdk_python/workflows/agent.py b/agents/strands_agents_sdk_python/workflows/agent.py new file mode 100644 index 00000000..46f26ec9 --- /dev/null +++ b/agents/strands_agents_sdk_python/workflows/agent.py @@ -0,0 +1,52 @@ +from datetime import timedelta +from temporalio import workflow + +from models.requests import AgentRequest, WeatherRequest + +MAX_ITERATIONS = 10 + + +@workflow.defn +class StrandsAgentWorkflow: + @workflow.run + async def run(self, user_input: str) -> str: + messages = [{"role": "user", "content": user_input}] + iterations = 0 + + # Agentic loop + while True: + iterations += 1 + if iterations > MAX_ITERATIONS: + return "Agent exceeded maximum iterations" + + response = await workflow.execute_activity( + "agent_activity", + AgentRequest(messages=messages), + start_to_close_timeout=timedelta(seconds=30) + ) + + if response.get("tool_calls"): + tool_results = [] + for tool_call in response["tool_calls"]: + result = await self._execute_tool(tool_call["tool_name"], tool_call.get("parameters", {})) + tool_results.append(f"{tool_call['tool_name']}: {result}") + + messages.append({ + "role": "assistant", + "content": f"Tool results: {' | '.join(tool_results)}" + }) + continue + + if response.get("final_answer"): + return response["final_answer"] + + return "Agent failed to provide a response" + + async def _execute_tool(self, tool_name: str, parameters: dict) -> str: + if tool_name == "get_time": + return await workflow.execute_activity("get_time_activity", start_to_close_timeout=timedelta(seconds=10)) + elif tool_name == "get_weather": + return await workflow.execute_activity("get_weather_activity", WeatherRequest(**parameters), start_to_close_timeout=timedelta(seconds=10)) + elif tool_name == "list_files": + return await workflow.execute_activity("list_files_activity", start_to_close_timeout=timedelta(seconds=10)) + return f"Unknown tool: {tool_name}" \ No newline at end of file From d9345b136fc389b7b0ac01a1da76db4f136e397c Mon Sep 17 00:00:00 2001 From: LondheShubham153 Date: Wed, 4 Feb 2026 15:57:23 +0530 Subject: [PATCH 2/2] added tests for strands agents workflows, models, activities --- .../strands_agents_sdk_python/pyproject.toml | 12 +- .../tests/__init__.py | 0 .../tests/test_activities.py | 84 ++++++++ .../tests/test_json_extraction.py | 67 ++++++ .../tests/test_models.py | 90 ++++++++ .../tests/test_workflow.py | 201 ++++++++++++++++++ 6 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 agents/strands_agents_sdk_python/tests/__init__.py create mode 100644 agents/strands_agents_sdk_python/tests/test_activities.py create mode 100644 agents/strands_agents_sdk_python/tests/test_json_extraction.py create mode 100644 agents/strands_agents_sdk_python/tests/test_models.py create mode 100644 agents/strands_agents_sdk_python/tests/test_workflow.py diff --git a/agents/strands_agents_sdk_python/pyproject.toml b/agents/strands_agents_sdk_python/pyproject.toml index 54cae7ce..5f46cafc 100644 --- a/agents/strands_agents_sdk_python/pyproject.toml +++ b/agents/strands_agents_sdk_python/pyproject.toml @@ -14,5 +14,15 @@ dependencies = [ "requests>=2.31.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + [tool.setuptools] -py-modules = [] # No modules to install, just dependencies \ No newline at end of file +py-modules = [] # No modules to install, just dependencies + +[tool.pytest.ini_options] +asyncio_mode = "auto" +pythonpath = ["."] \ No newline at end of file diff --git a/agents/strands_agents_sdk_python/tests/__init__.py b/agents/strands_agents_sdk_python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agents/strands_agents_sdk_python/tests/test_activities.py b/agents/strands_agents_sdk_python/tests/test_activities.py new file mode 100644 index 00000000..1dae66b9 --- /dev/null +++ b/agents/strands_agents_sdk_python/tests/test_activities.py @@ -0,0 +1,84 @@ +import pytest +from unittest.mock import patch, MagicMock +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from activities.tool_activities import get_time_activity, get_weather_activity, list_files_activity +from models.requests import WeatherRequest + + +class TestGetTimeActivity: + + @pytest.mark.asyncio + async def test_returns_formatted_time(self): + result = await get_time_activity() + # Should match format: YYYY-MM-DD HH:MM:SS + assert len(result) == 19 + assert result[4] == "-" + assert result[10] == " " + assert result[13] == ":" + + +class TestGetWeatherActivity: + + @pytest.mark.asyncio + async def test_returns_weather_for_city(self): + mock_response = MagicMock() + mock_response.text = "Sunny +22°C" + + with patch("activities.tool_activities.requests.get", return_value=mock_response) as mock_get: + result = await get_weather_activity(WeatherRequest(city="London")) + + mock_get.assert_called_once() + assert "London" in result + assert "Sunny" in result + + @pytest.mark.asyncio + async def test_calls_correct_api(self): + mock_response = MagicMock() + mock_response.text = "Cloudy +15°C" + + with patch("activities.tool_activities.requests.get", return_value=mock_response) as mock_get: + await get_weather_activity(WeatherRequest(city="Paris")) + + call_args = mock_get.call_args + assert "wttr.in/Paris" in call_args[0][0] + + +class TestListFilesActivity: + + @pytest.mark.asyncio + async def test_returns_python_files(self): + fake_files = ["main.py", "test.py", "readme.md", "config.json", "utils.py"] + + with patch("activities.tool_activities.os.listdir", return_value=fake_files): + result = await list_files_activity() + + assert "Python files:" in result + assert "main.py" in result + assert "test.py" in result + assert "utils.py" in result + assert "readme.md" not in result + + @pytest.mark.asyncio + async def test_limits_to_five_files(self): + fake_files = [f"file{i}.py" for i in range(10)] + + with patch("activities.tool_activities.os.listdir", return_value=fake_files): + result = await list_files_activity() + + # Should only show first 5 + assert "file0.py" in result + assert "file4.py" in result + assert "file5.py" not in result + + @pytest.mark.asyncio + async def test_handles_no_python_files(self): + fake_files = ["readme.md", "config.json"] + + with patch("activities.tool_activities.os.listdir", return_value=fake_files): + result = await list_files_activity() + + assert "Python files:" in result diff --git a/agents/strands_agents_sdk_python/tests/test_json_extraction.py b/agents/strands_agents_sdk_python/tests/test_json_extraction.py new file mode 100644 index 00000000..44cf2799 --- /dev/null +++ b/agents/strands_agents_sdk_python/tests/test_json_extraction.py @@ -0,0 +1,67 @@ +import pytest +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from activities.strands_agent import extract_json + + +class TestExtractJson: + + def test_valid_json_direct(self): + text = '{"tool_calls": [], "final_answer": "Hello", "reasoning": "test"}' + result = extract_json(text) + assert result == {"tool_calls": [], "final_answer": "Hello", "reasoning": "test"} + + def test_valid_json_with_whitespace(self): + text = ' \n {"tool_calls": [], "final_answer": "test"} \n ' + result = extract_json(text) + assert result["final_answer"] == "test" + + def test_json_embedded_in_text(self): + text = '''Here is my response: + {"tool_calls": [{"tool_name": "get_time", "parameters": {}}], "final_answer": null, "reasoning": "Need time"} + That's my answer.''' + result = extract_json(text) + assert result["tool_calls"][0]["tool_name"] == "get_time" + assert result["final_answer"] is None + + def test_json_with_tool_calls(self): + text = '{"tool_calls": [{"tool_name": "get_weather", "parameters": {"city": "London"}}], "final_answer": null, "reasoning": "Checking weather"}' + result = extract_json(text) + assert len(result["tool_calls"]) == 1 + assert result["tool_calls"][0]["tool_name"] == "get_weather" + assert result["tool_calls"][0]["parameters"]["city"] == "London" + + def test_json_with_final_answer(self): + text = '{"tool_calls": [], "final_answer": "The current time is 2024-01-15 10:30:00", "reasoning": "Got the time"}' + result = extract_json(text) + assert result["tool_calls"] == [] + assert "current time" in result["final_answer"] + + def test_invalid_json_raises_error(self): + text = "This is not JSON at all" + with pytest.raises(ValueError, match="No valid JSON found"): + extract_json(text) + + def test_malformed_json_raises_error(self): + text = '{"tool_calls": [}' + with pytest.raises(ValueError, match="No valid JSON found"): + extract_json(text) + + def test_empty_string_raises_error(self): + with pytest.raises(ValueError, match="No valid JSON found"): + extract_json("") + + def test_nested_json_objects(self): + text = '''{"tool_calls": [ + {"tool_name": "get_weather", "parameters": {"city": "New York", "units": "metric"}} + ], "final_answer": null, "reasoning": "User wants weather"}''' + result = extract_json(text) + assert result["tool_calls"][0]["parameters"]["units"] == "metric" + + def test_json_with_special_characters(self): + text = '{"tool_calls": [], "final_answer": "Temperature: 25\\u00b0C", "reasoning": "Done"}' + result = extract_json(text) + assert "Temperature" in result["final_answer"] diff --git a/agents/strands_agents_sdk_python/tests/test_models.py b/agents/strands_agents_sdk_python/tests/test_models.py new file mode 100644 index 00000000..005728e4 --- /dev/null +++ b/agents/strands_agents_sdk_python/tests/test_models.py @@ -0,0 +1,90 @@ +import pytest +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from models.requests import AgentRequest, WeatherRequest +from models.orchestrator import AgentResponse, ToolCall + + +class TestAgentRequest: + + def test_default_model_id(self): + request = AgentRequest(messages=[{"role": "user", "content": "hello"}]) + assert "claude" in request.model_id.lower() + + def test_custom_model_id(self): + request = AgentRequest( + messages=[{"role": "user", "content": "hello"}], + model_id="custom-model-123" + ) + assert request.model_id == "custom-model-123" + + def test_messages_preserved(self): + messages = [ + {"role": "user", "content": "What time is it?"}, + {"role": "assistant", "content": "Let me check."} + ] + request = AgentRequest(messages=messages) + assert len(request.messages) == 2 + assert request.messages[0]["role"] == "user" + + +class TestWeatherRequest: + + def test_city_required(self): + request = WeatherRequest(city="London") + assert request.city == "London" + + def test_missing_city_raises(self): + with pytest.raises(Exception): + WeatherRequest() + + +class TestToolCall: + + def test_with_parameters(self): + tool = ToolCall(tool_name="get_weather", parameters={"city": "Paris"}) + assert tool.tool_name == "get_weather" + assert tool.parameters["city"] == "Paris" + + def test_default_empty_parameters(self): + tool = ToolCall(tool_name="get_time") + assert tool.parameters == {} + + +class TestAgentResponse: + + def test_with_tool_calls(self): + response = AgentResponse( + tool_calls=[ToolCall(tool_name="get_time")], + final_answer=None, + reasoning="Need to check time" + ) + assert len(response.tool_calls) == 1 + assert response.final_answer is None + + def test_with_final_answer(self): + response = AgentResponse( + tool_calls=[], + final_answer="The time is 10:30 AM", + reasoning="Got the time" + ) + assert response.tool_calls == [] + assert response.final_answer == "The time is 10:30 AM" + + def test_defaults(self): + response = AgentResponse() + assert response.tool_calls == [] + assert response.final_answer is None + assert response.reasoning is None + + def test_serialization(self): + response = AgentResponse( + tool_calls=[ToolCall(tool_name="list_files", parameters={})], + final_answer=None, + reasoning="Listing files" + ) + data = response.model_dump() + assert data["tool_calls"][0]["tool_name"] == "list_files" diff --git a/agents/strands_agents_sdk_python/tests/test_workflow.py b/agents/strands_agents_sdk_python/tests/test_workflow.py new file mode 100644 index 00000000..82ce96bb --- /dev/null +++ b/agents/strands_agents_sdk_python/tests/test_workflow.py @@ -0,0 +1,201 @@ +import pytest +from unittest.mock import patch +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from workflows.agent import StrandsAgentWorkflow + + +@pytest.fixture +async def env(): + async with await WorkflowEnvironment.start_time_skipping() as env: + yield env + + +class TestStrandsAgentWorkflow: + + @pytest.mark.asyncio + async def test_returns_final_answer_directly(self, env): + from temporalio import activity + + @activity.defn(name="agent_activity") + async def mock_agent_activity(request): + return { + "tool_calls": [], + "final_answer": "Hello! How can I help you?", + "reasoning": "Simple greeting" + } + + async with Worker( + env.client, + task_queue="test-queue", + workflows=[StrandsAgentWorkflow], + activities=[mock_agent_activity], + ): + result = await env.client.execute_workflow( + StrandsAgentWorkflow.run, + "Hello", + id="test-direct-answer", + task_queue="test-queue", + ) + assert result == "Hello! How can I help you?" + + @pytest.mark.asyncio + async def test_executes_tool_and_returns_answer(self, env): + call_count = 0 + + async def mock_agent_activity(request): + nonlocal call_count + call_count += 1 + + if call_count == 1: + return { + "tool_calls": [{"tool_name": "get_time", "parameters": {}}], + "final_answer": None, + "reasoning": "Checking time" + } + else: + return { + "tool_calls": [], + "final_answer": "The current time is 10:30 AM", + "reasoning": "Got the time" + } + + async def mock_get_time(): + return "2024-01-15 10:30:00" + + from temporalio import activity + + @activity.defn(name="agent_activity") + async def agent_activity_wrapper(request): + return await mock_agent_activity(request) + + @activity.defn(name="get_time_activity") + async def get_time_wrapper(): + return await mock_get_time() + + async with Worker( + env.client, + task_queue="test-queue", + workflows=[StrandsAgentWorkflow], + activities=[agent_activity_wrapper, get_time_wrapper], + ): + result = await env.client.execute_workflow( + StrandsAgentWorkflow.run, + "What time is it?", + id="test-tool-call", + task_queue="test-queue", + ) + assert "10:30" in result + assert call_count == 2 + + @pytest.mark.asyncio + async def test_max_iterations_exceeded(self, env): + async def mock_agent_activity_loop(request): + return { + "tool_calls": [{"tool_name": "get_time", "parameters": {}}], + "final_answer": None, + "reasoning": "Still checking" + } + + async def mock_get_time(): + return "2024-01-15 10:30:00" + + from temporalio import activity + + @activity.defn(name="agent_activity") + async def agent_activity_wrapper(request): + return await mock_agent_activity_loop(request) + + @activity.defn(name="get_time_activity") + async def get_time_wrapper(): + return await mock_get_time() + + async with Worker( + env.client, + task_queue="test-queue", + workflows=[StrandsAgentWorkflow], + activities=[agent_activity_wrapper, get_time_wrapper], + ): + result = await env.client.execute_workflow( + StrandsAgentWorkflow.run, + "Keep looping", + id="test-max-iterations", + task_queue="test-queue", + ) + assert "exceeded maximum iterations" in result + + @pytest.mark.asyncio + async def test_handles_unknown_tool(self, env): + call_count = 0 + + async def mock_agent_activity(request): + nonlocal call_count + call_count += 1 + + if call_count == 1: + return { + "tool_calls": [{"tool_name": "unknown_tool", "parameters": {}}], + "final_answer": None, + "reasoning": "Trying unknown tool" + } + else: + return { + "tool_calls": [], + "final_answer": "Done after unknown tool", + "reasoning": "Finished" + } + + from temporalio import activity + + @activity.defn(name="agent_activity") + async def agent_activity_wrapper(request): + return await mock_agent_activity(request) + + async with Worker( + env.client, + task_queue="test-queue", + workflows=[StrandsAgentWorkflow], + activities=[agent_activity_wrapper], + ): + result = await env.client.execute_workflow( + StrandsAgentWorkflow.run, + "Use unknown tool", + id="test-unknown-tool", + task_queue="test-queue", + ) + assert result == "Done after unknown tool" + + @pytest.mark.asyncio + async def test_no_response_returns_failure_message(self, env): + async def mock_agent_activity_empty(request): + return { + "tool_calls": [], + "final_answer": None, + "reasoning": None + } + + from temporalio import activity + + @activity.defn(name="agent_activity") + async def agent_activity_wrapper(request): + return await mock_agent_activity_empty(request) + + async with Worker( + env.client, + task_queue="test-queue", + workflows=[StrandsAgentWorkflow], + activities=[agent_activity_wrapper], + ): + result = await env.client.execute_workflow( + StrandsAgentWorkflow.run, + "Get empty response", + id="test-empty-response", + task_queue="test-queue", + ) + assert "failed to provide" in result