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..b542c7481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "boto3>=1.26.0,<2.0.0", "botocore>=1.29.0,<2.0.0", "docstring_parser>=0.15,<1.0", + "jsonschema>=4.0.0,<5.0.0", "mcp>=1.11.0,<2.0.0", "pydantic>=2.4.0,<3.0.0", "typing-extensions>=4.13.2,<5.0.0", diff --git a/src/strands/experimental/__init__.py b/src/strands/experimental/__init__.py index c40d0fcec..86618c153 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 config_to_agent + +__all__ = ["config_to_agent"] diff --git a/src/strands/experimental/agent_config.py b/src/strands/experimental/agent_config.py new file mode 100644 index 000000000..4424d88f7 --- /dev/null +++ b/src/strands/experimental/agent_config.py @@ -0,0 +1,186 @@ +"""Experimental agent configuration utilities. + +This module provides utilities for creating agents from configuration files or dictionaries. + +Note: Configuration-based agent setup only works for tools that don't require code-based +instantiation. For tools that need constructor arguments or complex setup, use the +programmatic approach after creating the agent: + + agent = config_to_agent("config.json") + # Add tools that need code-based instantiation + agent.process_tools([ToolWithConfigArg(HttpsConnection("localhost"))]) +""" + +import importlib +import json +import os +from pathlib import Path + +import jsonschema +from jsonschema import ValidationError + +from ..agent import Agent + +# JSON Schema for agent configuration +AGENT_CONFIG_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Agent Configuration", + "description": "Configuration schema for creating agents", + "type": "object", + "properties": { + "name": { + "description": "Name of the agent", + "type": ["string", "null"], + "default": None + }, + "model": { + "description": "The model ID to use for this agent. If not specified, uses the default model.", + "type": ["string", "null"], + "default": None + }, + "prompt": { + "description": "The system prompt for the agent. Provides high level context to the agent.", + "type": ["string", "null"], + "default": None + }, + "tools": { + "description": "List of tools the agent can use. Can be file paths, Python module names, or @tool annotated functions in files.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "additionalProperties": False +} + +# Pre-compile validator for better performance +_VALIDATOR = jsonschema.Draft7Validator(AGENT_CONFIG_SCHEMA) + + +def _is_filepath(tool_path: str) -> bool: + """Check if the tool string is a file path.""" + return os.path.exists(tool_path) or tool_path.endswith('.py') + + +def _validate_tools(tools: list[str]) -> None: + """Validate that tools can be loaded as files or modules.""" + for tool in tools: + if _is_filepath(tool): + # File path - will be handled by Agent's tool loading + continue + + try: + # Try to import as module + importlib.import_module(tool) + except ImportError: + # Not a file and not a module - check if it might be a function reference + if '.' in tool: + module_path, func_name = tool.rsplit('.', 1) + try: + module = importlib.import_module(module_path) + if not hasattr(module, func_name): + raise ValueError( + f"Function '{func_name}' not found in module '{module_path}'. " + f"Ensure the function exists and is annotated with @tool." + ) + except ImportError: + raise ValueError( + f"Module '{module_path}' not found. " + f"Ensure the module exists and is importable, or use a valid file path." + ) + else: + raise ValueError( + f"Tool '{tool}' not found. " + f"The configured tool is not annotated with @tool, and is not a module or file." + ) + + +def config_to_agent(config: str | dict[str, any], **kwargs) -> Agent: + """Create an Agent from a configuration file or dictionary. + + This function supports tools that can be loaded declaratively (file paths, module names, + or @tool annotated functions). For tools requiring code-based instantiation with constructor + arguments, add them programmatically after creating the agent: + + agent = config_to_agent("config.json") + agent.process_tools([ToolWithConfigArg(HttpsConnection("localhost"))]) + + Args: + config: Either a file path (with optional file:// prefix) or a configuration dictionary + **kwargs: Additional keyword arguments to pass to the Agent constructor + + Returns: + Agent: A configured Agent instance + + Raises: + FileNotFoundError: If the configuration file doesn't exist + json.JSONDecodeError: If the configuration file contains invalid JSON + ValueError: If the configuration is invalid or tools cannot be loaded + + Examples: + Create agent from file: + >>> agent = config_to_agent("/path/to/config.json") + + Create agent from file with file:// prefix: + >>> agent = config_to_agent("file:///path/to/config.json") + + Create agent from dictionary: + >>> config = {"model": "anthropic.claude-3-5-sonnet-20241022-v2:0", "tools": ["calculator"]} + >>> agent = config_to_agent(config) + """ + # Parse configuration + if isinstance(config, str): + # Handle file path + file_path = config + + # Remove file:// prefix if present + if file_path.startswith("file://"): + file_path = file_path[7:] + + # Load JSON from file + config_path = Path(file_path) + if not config_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {file_path}") + + with open(config_path, 'r') as f: + config_dict = json.load(f) + elif isinstance(config, dict): + config_dict = config.copy() + else: + raise ValueError("Config must be a file path string or dictionary") + + # Validate configuration against schema + try: + _VALIDATOR.validate(config_dict) + except ValidationError as e: + # Provide more detailed error message + error_path = " -> ".join(str(p) for p in e.absolute_path) if e.absolute_path else "root" + raise ValueError(f"Configuration validation error at {error_path}: {e.message}") from e + + # Validate tools can be loaded + if "tools" in config_dict and config_dict["tools"]: + _validate_tools(config_dict["tools"]) + + # Prepare Agent constructor arguments + agent_kwargs = {} + + # Map configuration keys to Agent constructor parameters + config_mapping = { + "model": "model", + "prompt": "system_prompt", + "tools": "tools", + "name": "name", + } + + # Only include non-None values from config + for config_key, agent_param in config_mapping.items(): + if config_key in config_dict and config_dict[config_key] is not None: + agent_kwargs[agent_param] = config_dict[config_key] + + # Override with any additional kwargs provided + agent_kwargs.update(kwargs) + + # Create and return Agent + return Agent(**agent_kwargs) diff --git a/tests/strands/experimental/test_agent_config.py b/tests/strands/experimental/test_agent_config.py new file mode 100644 index 000000000..42d04a7a3 --- /dev/null +++ b/tests/strands/experimental/test_agent_config.py @@ -0,0 +1,150 @@ +"""Tests for experimental config_to_agent function.""" + +import json +import os +import platform +import tempfile +from unittest.mock import patch + +import pytest + +from strands.experimental import config_to_agent + + +class TestConfigToAgent: + """Test experimental config_to_agent functionality.""" + + def test_config_to_agent_with_dict(self): + """Test config_to_agent can be created with dict config.""" + config = {"model": "test-model"} + agent = config_to_agent(config) + assert agent.model.config["model_id"] == "test-model" + + def test_config_to_agent_with_system_prompt(self): + """Test config_to_agent handles system prompt correctly.""" + config = {"model": "test-model", "prompt": "Test prompt"} + agent = config_to_agent(config) + assert agent.system_prompt == "Test prompt" + + def test_config_to_agent_with_tools_list(self): + """Test config_to_agent handles tools list without failing.""" + # Use a simple test that doesn't require actual tool loading + config = {"model": "test-model", "tools": []} + agent = config_to_agent(config) + assert agent.model.config["model_id"] == "test-model" + + def test_config_to_agent_with_kwargs_override(self): + """Test that kwargs can override config values.""" + config = {"model": "test-model", "prompt": "Config prompt"} + agent = config_to_agent(config, system_prompt="Override prompt") + assert agent.system_prompt == "Override prompt" + + def test_config_to_agent_file_prefix_required(self): + """Test that file paths without file:// prefix work.""" + import tempfile + import json + + config_data = {"model": "test-model"} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as f: + json.dump(config_data, f) + f.flush() + + agent = config_to_agent(f.name) + assert agent.model.config["model_id"] == "test-model" + + def test_config_to_agent_file_prefix_valid(self): + """Test that file:// prefix is properly handled.""" + config_data = {"model": "test-model", "prompt": "Test prompt"} + + if platform.system() == "Windows": + # Use mkstemp approach on Windows for better permission handling + fd, temp_path = tempfile.mkstemp(suffix=".json") + try: + with os.fdopen(fd, 'w') as f: + json.dump(config_data, f) + + agent = config_to_agent(f"file://{temp_path}") + assert agent.model.config["model_id"] == "test-model" + assert agent.system_prompt == "Test prompt" + finally: + os.unlink(temp_path) + else: + # Use NamedTemporaryFile on non-Windows platforms + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=True) as f: + json.dump(config_data, f) + f.flush() # Ensure data is written to disk + + agent = config_to_agent(f"file://{f.name}") + assert agent.model.config["model_id"] == "test-model" + assert agent.system_prompt == "Test prompt" + + def test_config_to_agent_file_not_found(self): + """Test that FileNotFoundError is raised for missing files.""" + with pytest.raises(FileNotFoundError, match="Configuration file not found"): + config_to_agent("/nonexistent/path/config.json") + + def test_config_to_agent_invalid_json(self): + """Test that JSONDecodeError is raised for invalid JSON.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("invalid json content") + temp_path = f.name + + try: + with pytest.raises(json.JSONDecodeError): + config_to_agent(temp_path) + finally: + os.unlink(temp_path) + + def test_config_to_agent_invalid_config_type(self): + """Test that ValueError is raised for invalid config types.""" + with pytest.raises(ValueError, match="Config must be a file path string or dictionary"): + config_to_agent(123) + + def test_config_to_agent_with_name(self): + """Test config_to_agent handles agent name.""" + config = {"model": "test-model", "name": "TestAgent"} + agent = config_to_agent(config) + assert agent.name == "TestAgent" + + def test_config_to_agent_ignores_none_values(self): + """Test that None values in config are ignored.""" + config = {"model": "test-model", "prompt": None, "name": None} + agent = config_to_agent(config) + assert agent.model.config["model_id"] == "test-model" + # Agent should use its defaults for None values + + def test_config_to_agent_validation_error_invalid_field(self): + """Test that invalid fields raise validation errors.""" + config = {"model": "test-model", "invalid_field": "value"} + with pytest.raises(ValueError, match="Configuration validation error"): + config_to_agent(config) + + def test_config_to_agent_validation_error_wrong_type(self): + """Test that wrong field types raise validation errors.""" + config = {"model": "test-model", "tools": "not-a-list"} + with pytest.raises(ValueError, match="Configuration validation error"): + config_to_agent(config) + + def test_config_to_agent_validation_error_invalid_tool_item(self): + """Test that invalid tool items raise validation errors.""" + config = {"model": "test-model", "tools": ["valid-tool", 123]} + with pytest.raises(ValueError, match="Configuration validation error"): + config_to_agent(config) + + def test_config_to_agent_validation_error_invalid_tool(self): + """Test that invalid tools raise helpful error messages.""" + config = {"model": "test-model", "tools": ["nonexistent_tool"]} + with pytest.raises(ValueError, match="The configured tool is not annotated with @tool"): + config_to_agent(config) + + def test_config_to_agent_validation_error_missing_module(self): + """Test that missing modules raise helpful error messages.""" + config = {"model": "test-model", "tools": ["nonexistent.module.tool"]} + with pytest.raises(ValueError, match="Module 'nonexistent.module' not found"): + config_to_agent(config) + + def test_config_to_agent_validation_error_missing_function(self): + """Test that missing functions in existing modules raise helpful error messages.""" + config = {"model": "test-model", "tools": ["json.nonexistent_function"]} + with pytest.raises(ValueError, match="Function 'nonexistent_function' not found in module 'json'"): + config_to_agent(config)