diff --git a/.gitignore b/.gitignore index 888a96bbc..e92a233f8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ __pycache__* .vscode dist repl_state -.kiro \ No newline at end of file +.kiro +uv.lock diff --git a/pyproject.toml b/pyproject.toml index af8e45ffc..fe582b5a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,7 @@ dependencies = [ "pytest-asyncio>=1.0.0,<1.3.0", "pytest-xdist>=3.0.0,<4.0.0", "moto>=5.1.0,<6.0.0", + "strands-agents-tools>=0.2.0,<1.0.0", ] [[tool.hatch.envs.hatch-test.matrix]] diff --git a/src/strands/experimental/__init__.py b/src/strands/experimental/__init__.py index c40d0fcec..763a41bbf 100644 --- a/src/strands/experimental/__init__.py +++ b/src/strands/experimental/__init__.py @@ -2,3 +2,7 @@ This module implements experimental features that are subject to change in future revisions without notice. """ + +from .agent_config import AgentConfig + +__all__ = ["AgentConfig"] diff --git a/src/strands/experimental/agent_config.py b/src/strands/experimental/agent_config.py new file mode 100644 index 000000000..f492d35b0 --- /dev/null +++ b/src/strands/experimental/agent_config.py @@ -0,0 +1,193 @@ +"""Experimental agent configuration with enhanced instantiation patterns.""" + +import importlib +import json +from typing import TYPE_CHECKING, Any + +from ..tools.registry import ToolRegistry + +if TYPE_CHECKING: + # Import here to avoid circular imports: + # experimental/agent_config.py -> agent.agent -> event_loop.event_loop -> + # experimental.hooks -> experimental.__init__.py -> AgentConfig + from ..agent.agent import Agent + +# File prefix for configuration file paths +FILE_PREFIX = "file://" + +# Minimum viable list of tools to enable agent building +# This list is experimental and will be revisited as tools evolve +DEFAULT_TOOLS = ["file_read", "editor", "http_request", "shell", "use_agent"] + + +class AgentConfig: + """Agent configuration with to_agent() method and ToolRegistry integration. + + Example config.json: + { + "model": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "prompt": "You are a helpful assistant", + "tools": ["file_read", "editor"] + } + """ + + def __init__( + self, + config_source: str | dict[str, Any], + tool_registry: ToolRegistry | None = None, + raise_exception_on_missing_tool: bool = True, + ): + """Initialize AgentConfig from file path or dictionary. + + Args: + config_source: Path to JSON config file (must start with 'file://') or config dictionary + tool_registry: Optional ToolRegistry to select tools from when 'tools' is specified in config + raise_exception_on_missing_tool: If False, skip missing tools instead of raising ImportError + + Example: + # Dictionary config + config = AgentConfig({ + "model": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "prompt": "You are a helpful assistant", + "tools": ["file_read", "editor"] + }) + + # File config + config = AgentConfig("file://config.json") + """ + if isinstance(config_source, str): + # Require file:// prefix for file paths + if not config_source.startswith(FILE_PREFIX): + raise ValueError(f"File paths must be prefixed with '{FILE_PREFIX}'") + + # Remove file:// prefix and load from file + file_path = config_source.removeprefix(FILE_PREFIX) + with open(file_path, "r") as f: + config_data = json.load(f) + else: + # Use dictionary directly + config_data = config_source + + self.model = config_data.get("model") + self.system_prompt = config_data.get("prompt") # Only accept 'prompt' key + self._raise_exception_on_missing_tool = raise_exception_on_missing_tool + + # Handle tool selection from ToolRegistry + if tool_registry is not None: + self._tool_registry = tool_registry + else: + # Create default ToolRegistry with strands_tools + self._tool_registry = self._create_default_tool_registry() + + # Process tools configuration if provided + config_tools = config_data.get("tools") + + # Track configured tools separately from full tool pool + self._configured_tools = [] + + # Apply tool selection if specified + if config_tools is not None: + # Validate all tool names exist in the ToolRegistry + available_tools = self._tool_registry.registry.keys() + + missing_tools = set(config_tools).difference(available_tools) + if missing_tools and self._raise_exception_on_missing_tool: + raise ValueError( + f"Tool(s) '{missing_tools}' not found in ToolRegistry. Available tools: {available_tools}" + ) + + for tool_name in config_tools: + if tool_name in self._tool_registry.registry: + tool = self._tool_registry.registry[tool_name] + self._configured_tools.append(tool) + # If no tools specified in config, use no tools (empty list) + + def _create_default_tool_registry(self) -> ToolRegistry: + """Create default ToolRegistry with strands_tools.""" + tool_registry = ToolRegistry() + + try: + tool_modules = [importlib.import_module(f"strands_tools.{tool}") for tool in DEFAULT_TOOLS] + tool_registry.process_tools(tool_modules) + except ImportError as e: + if self._raise_exception_on_missing_tool: + raise ImportError( + "strands_tools is not available and no ToolRegistry was specified. " + "Either install strands_tools with 'pip install strands-agents-tools' " + "or provide your own ToolRegistry with your own tools." + ) from e + + return tool_registry + + @property + def tool_registry(self) -> ToolRegistry: + """Get the full ToolRegistry (superset of all available tools). + + Returns: + ToolRegistry instance containing all available tools + """ + return self._tool_registry + + @property + def configured_tools(self) -> list: + """Get the configured tools (subset selected for this agent). + + Returns: + List of tools configured for this agent + """ + return self._configured_tools + + def to_agent(self, **kwargs: Any) -> "Agent": + """Create an Agent instance from this configuration. + + Args: + **kwargs: Additional parameters to override config values. + Supports all Agent constructor parameters. + + Returns: + Configured Agent instance + + Example: + # Using default tools from strands_tools + config = AgentConfig({ + "model": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "prompt": "You are a helpful assistant", + "tools": ["file_read"] + }) + agent = config.to_agent() + response = agent("Read the contents of README.md") + + # Using custom ToolRegistry + from strands import tool + + @tool + def custom_tool(input: str) -> str: + return f"Custom: {input}" + + custom_tool_registry = ToolRegistry() + custom_tool_registry.process_tools([custom_tool]) + config = AgentConfig({ + "model": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "prompt": "You are a custom assistant", + "tools": ["custom_tool"] + }, tool_registry=custom_tool_registry) + agent = config.to_agent() + """ + # Import at runtime since TYPE_CHECKING import is not available during execution + from ..agent.agent import Agent + + # Start with config values + agent_params = {} + + if self.model is not None: + agent_params["model"] = self.model + if self.system_prompt is not None: + agent_params["system_prompt"] = self.system_prompt + + # Use configured tools (subset of tool pool) + agent_params["tools"] = self._configured_tools + + # Override with any other provided kwargs + agent_params.update(kwargs) + + return Agent(**agent_params) diff --git a/tests/strands/experimental/test_agent_config.py b/tests/strands/experimental/test_agent_config.py new file mode 100644 index 000000000..d0cbddb75 --- /dev/null +++ b/tests/strands/experimental/test_agent_config.py @@ -0,0 +1,208 @@ +"""Tests for experimental AgentConfig.""" + +import re +from unittest.mock import patch + +import pytest + +from strands.experimental.agent_config import AgentConfig +from strands.tools.registry import ToolRegistry +from strands.types.tools import AgentTool + + +class TestAgentConfig: + """Test experimental AgentConfig functionality.""" + + class MockTool(AgentTool): + def __init__(self, name): + self._name = name + + @property + def tool_name(self): + return self._name + + @property + def tool_type(self): + return "mock" + + @property + def tool_spec(self): + return {"name": self._name, "type": "mock"} + + @property + def _is_dynamic(self): + return False + + def stream(self, input_data, context): + return iter([]) + + def test_agent_config_creation(self): + """Test AgentConfig can be created with dict config.""" + # Provide empty ToolRegistry since strands_tools not available in tests + config = AgentConfig({"model": "test-model"}, tool_registry=ToolRegistry()) + assert config.model == "test-model" + + def test_agent_config_with_tools(self): + """Test AgentConfig with basic configuration.""" + + config = AgentConfig({"model": "test-model", "prompt": "Test prompt"}, tool_registry=ToolRegistry()) + + assert config.model == "test-model" + assert config.system_prompt == "Test prompt" + + def test_agent_config_file_prefix_required(self): + """Test that file paths must have file:// prefix.""" + + with pytest.raises(ValueError, match="File paths must be prefixed with 'file://'"): + AgentConfig("/path/to/config.json") + + def test_agent_config_file_prefix_valid(self): + """Test that file:// prefix is properly handled.""" + import json + import tempfile + + # Create a temporary config file + config_data = {"model": "test-model", "prompt": "Test prompt"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as f: + json.dump(config_data, f) + f.flush() # Ensure data is written to disk + + config = AgentConfig(f"file://{f.name}", tool_registry=ToolRegistry()) + assert config.model == "test-model" + assert config.system_prompt == "Test prompt" + + @patch("strands.agent.agent.Agent") + def test_to_agent_calls_agent_constructor(self, mock_agent): + """Test that to_agent calls Agent constructor with correct parameters.""" + + config = AgentConfig({"model": "test-model", "prompt": "Test prompt"}, tool_registry=ToolRegistry()) + + config.to_agent() + + mock_agent.assert_called_once_with(model="test-model", tools=[], system_prompt="Test prompt") + + def test_agent_config_has_tool_registry(self): + """Test AgentConfig creates ToolRegistry and tracks configured tools.""" + + config = AgentConfig({"model": "test-model"}, tool_registry=ToolRegistry()) + assert hasattr(config, "tool_registry") + assert hasattr(config, "configured_tools") + assert config.configured_tools == [] # No tools configured + + @patch("strands.agent.agent.Agent") + def test_to_agent_with_empty_tool_registry(self, mock_agent): + """Test that to_agent uses empty ToolRegistry by default.""" + + config = AgentConfig({"model": "test-model"}, tool_registry=ToolRegistry()) + config.to_agent() + + # Should be called with empty tools list + mock_agent.assert_called_once() + call_args = mock_agent.call_args[1] + assert "tools" in call_args + assert call_args["tools"] == [] + + def test_agent_config_with_tool_registry_constructor(self): + """Test AgentConfig with ToolRegistry parameter in constructor.""" + # Create mock tools + tool1 = self.MockTool("calculator") + tool2 = self.MockTool("web_search") + + # Create ToolRegistry with tools + tool_registry = ToolRegistry() + tool_registry.process_tools([tool1, tool2]) + + # Create config with tool selection + config = AgentConfig( + {"model": "test-model", "prompt": "Test prompt", "tools": ["calculator"]}, tool_registry=tool_registry + ) + + # Should have selected only calculator + assert len(config.configured_tools) == 1 + assert config.configured_tools[0].tool_name == "calculator" + + def test_agent_config_tool_validation_error(self): + """Test that invalid tool names raise validation error.""" + tool1 = self.MockTool("calculator") + tool_registry = ToolRegistry() + tool_registry.process_tools([tool1]) + + # Should raise error for unknown tool + with pytest.raises( + ValueError, + match=re.escape( + "Tool(s) '{'unknown_tool'}' not found in ToolRegistry. Available tools: dict_keys(['calculator'])" + ), + ): + AgentConfig({"model": "test-model", "tools": ["unknown_tool"]}, tool_registry=tool_registry) + + def test_agent_config_tools_without_tool_registry_error(self): + """Test that config can load tools from default ToolRegistry when strands_tools is available.""" + + config = AgentConfig({"model": "test-model", "tools": ["file_read"]}) + assert len(config.configured_tools) == 1 + assert config.configured_tools[0].tool_name == "file_read" + + @patch("importlib.import_module") + def test_agent_config_import_error(self, mock_import): + """Test that import error for strands_tools is handled correctly.""" + mock_import.side_effect = ImportError("No module named 'strands_tools'") + + with pytest.raises(ImportError, match="strands_tools is not available and no ToolRegistry was specified"): + AgentConfig({"model": "test-model"}) + + def test_agent_config_skip_missing_tools(self): + """Test that missing strands_tools can be skipped with flag.""" + # Should not raise error when flag is False and no ToolRegistry provided + config = AgentConfig({"model": "test-model"}, raise_exception_on_missing_tool=False) + assert config.model == "test-model" + assert config.configured_tools == [] # No tools loaded since strands_tools missing + + def test_agent_config_skip_missing_tools_with_selection(self): + """Test that missing tools in ToolRegistry can be skipped with flag.""" + + existing_tool = self.MockTool("existing_tool") + custom_tool_registry = ToolRegistry() + custom_tool_registry.process_tools([existing_tool]) + + # Should skip missing tool when flag is False + config = AgentConfig( + { + "model": "test-model", + "tools": ["existing_tool", "missing_tool"], # One exists, one doesn't + }, + tool_registry=custom_tool_registry, + raise_exception_on_missing_tool=False, + ) + + # Should only have the existing tool + assert len(config.configured_tools) == 1 + assert config.configured_tools[0].tool_name == "existing_tool" + + def test_agent_config_missing_tool_validation_with_flag_true(self): + """Test that missing tools still raise error when flag is True.""" + + existing_tool = self.MockTool("existing_tool") + custom_tool_registry = ToolRegistry() + custom_tool_registry.process_tools([existing_tool]) + + # Should raise error for missing tool when flag is True (default) + with pytest.raises( + ValueError, + match=re.escape( + "Tool(s) '{'missing_tool'}' not found in ToolRegistry. Available tools: dict_keys(['existing_tool'])" + ), + ): + AgentConfig( + {"model": "test-model", "tools": ["missing_tool"]}, + tool_registry=custom_tool_registry, + raise_exception_on_missing_tool=True, + ) + + def test_agent_config_loads_from_default_tools_without_tool_registry(self): + """Test that config can load tools from default strands_tools without explicit tool registry.""" + + config = AgentConfig({"model": "test-model", "tools": ["file_read"]}) + # Verify the tool was loaded from the default tool registry + assert len(config.configured_tools) == 1 + assert config.configured_tools[0].tool_name == "file_read"