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..7545b2094 --- /dev/null +++ b/src/strands/experimental/agent_config.py @@ -0,0 +1,138 @@ +"""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 json +from pathlib import Path +from typing import Any + +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 config_to_agent(config: str | dict[str, Any], **kwargs: dict[str, Any]) -> 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 + + # 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..e6188079b --- /dev/null +++ b/tests/strands/experimental/test_agent_config.py @@ -0,0 +1,172 @@ +"""Tests for experimental config_to_agent function.""" + +import json +import os +import tempfile + +import pytest + +from strands.experimental import config_to_agent + + +def test_config_to_agent_with_dict(): + """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(): + """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(): + """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(): + """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(): + """Test that file paths without file:// prefix work.""" + import json + import tempfile + + config_data = {"model": "test-model"} + temp_path = "" + + # We need to create files like this for windows compatibility + try: + with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f: + json.dump(config_data, f) + f.flush() + temp_path = f.name + + agent = config_to_agent(temp_path) + assert agent.model.config["model_id"] == "test-model" + finally: + # Clean up the temporary file + if os.path.exists(temp_path): + os.remove(temp_path) + + +def test_config_to_agent_file_prefix_valid(): + """Test that file:// prefix is properly handled.""" + config_data = {"model": "test-model", "prompt": "Test prompt"} + temp_path = "" + + try: + with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f: + json.dump(config_data, f) + f.flush() + temp_path = f.name + + agent = config_to_agent(f"file://{temp_path}") + assert agent.model.config["model_id"] == "test-model" + assert agent.system_prompt == "Test prompt" + finally: + # Clean up the temporary file + if os.path.exists(temp_path): + os.remove(temp_path) + + +def test_config_to_agent_file_not_found(): + """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(): + """Test that JSONDecodeError is raised for invalid JSON.""" + try: + with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f: + f.write("invalid json content") + temp_path = f.name + + with pytest.raises(json.JSONDecodeError): + config_to_agent(temp_path) + finally: + # Clean up the temporary file + if os.path.exists(temp_path): + os.remove(temp_path) + + +def test_config_to_agent_invalid_config_type(): + """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(): + """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(): + """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(): + """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(): + """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(): + """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(): + """Test that invalid tools raise helpful error messages.""" + config = {"model": "test-model", "tools": ["nonexistent_tool"]} + with pytest.raises(ValueError, match="Failed to load tool nonexistent_tool"): + config_to_agent(config) + + +def test_config_to_agent_validation_error_missing_module(): + """Test that missing modules raise helpful error messages.""" + config = {"model": "test-model", "tools": ["nonexistent.module.tool"]} + with pytest.raises(ValueError, match="Failed to load tool nonexistent.module.tool"): + config_to_agent(config) + + +def test_config_to_agent_validation_error_missing_function(): + """Test that missing functions in existing modules raise helpful error messages.""" + config = {"model": "test-model", "tools": ["json.nonexistent_function"]} + with pytest.raises(ValueError, match="Failed to load tool json.nonexistent_function"): + config_to_agent(config) + + +def test_config_to_agent_with_tool(): + """Test that missing functions in existing modules raise helpful error messages.""" + config = {"model": "test-model", "tools": ["tests.fixtures.say_tool:say"]} + agent = config_to_agent(config) + assert "say" in agent.tool_names