Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6bbe313
feat: add experimental AgentConfig with comprehensive tool management
mrlee-amazon Sep 26, 2025
b5a360a
fix: remove AgentConfig import from experimental/__init__.py
mrlee-amazon Sep 26, 2025
ccec79c
fix: remove strands-agents-tools test dependency
mrlee-amazon Sep 26, 2025
13f25fd
test: remove test that depends on strands_tools availability
mrlee-amazon Sep 26, 2025
280f85a
test: add back tests with proper mocking for strands_tools
mrlee-amazon Sep 26, 2025
f4ed508
test: fix Windows compatibility for file prefix test
mrlee-amazon Sep 26, 2025
f372666
refactor: replace AgentConfig class with config_to_agent function
mrlee-amazon Sep 26, 2025
c2d1baa
feat: limit config_to_agent to core configuration keys
mrlee-amazon Sep 26, 2025
d49782e
fix: use native Python typing instead of typing module
mrlee-amazon Sep 26, 2025
3b4f9fd
test: simplify file prefix test with proper context manager
mrlee-amazon Sep 26, 2025
2f5d692
feat: add JSON schema validation to config_to_agent
mrlee-amazon Sep 26, 2025
874c42f
refactor: move JSON schema to separate file
mrlee-amazon Sep 26, 2025
383b18b
perf: use pre-compiled JSON schema validator
mrlee-amazon Sep 26, 2025
a051bef
feat: add tool validation and clarify limitations
mrlee-amazon Sep 27, 2025
953ae34
fix: improve tool validation error messages and add comprehensive tests
mrlee-amazon Sep 27, 2025
811afab
fix: reference module instead of tool in error message
mrlee-amazon Sep 27, 2025
da9ff2e
revert: change error message back to reference tool
mrlee-amazon Sep 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ __pycache__*
.vscode
dist
repl_state
.kiro
.kiro
uv.lock
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/strands/experimental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
186 changes: 186 additions & 0 deletions src/strands/experimental/agent_config.py
Original file line number Diff line number Diff line change
@@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker: can we support mcp_servers, a2a_servers, load_tool_from_directory, system_prompt (not as prompt given below), provider, session id, s3 session support etc here?

"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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we support multi-agents as array of agents instead of just support one agent?

150 changes: 150 additions & 0 deletions tests/strands/experimental/test_agent_config.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add tests for loading module tools, as well as specific functions from a module? You can add the tools to the tests/fixtures directory

Original file line number Diff line number Diff line change
@@ -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)
Loading