Skip to content

Commit b69478b

Browse files
mr-leemrlee-amazonUnshure
authored
feat: add experimental AgentConfig with comprehensive tool management (#935)
* feat: add experimental AgentConfig with comprehensive tool management - Add AgentConfig class for declarative agent configuration via JSON/dict - Support file:// prefix for loading configurations from JSON files - Implement ToolRegistry integration with automatic default tool loading - Add raise_exception_on_missing_tool parameter for flexible error handling - Support tool selection from registry via tool names in config - Add comprehensive test coverage for all configuration scenarios - Move hook events from experimental to production with updated names - Add OpenAI model provider enhancements and Gemini model improvements - Update event loop and tool executors to use production hook events 🤖 Assisted by Amazon Q Developer * fix: remove AgentConfig import from experimental/__init__.py - Reset experimental/__init__.py to not import AgentConfig by default - This may resolve import issues in CI environments - AgentConfig can still be imported directly from strands.experimental.agent_config 🤖 Assisted by Amazon Q Developer * fix: remove strands-agents-tools test dependency - Reset pyproject.toml to not include strands-agents-tools as test dependency - Tests handle missing strands_tools gracefully with mocking - This should resolve CI dependency issues 🤖 Assisted by Amazon Q Developer * test: remove test that depends on strands_tools availability - Remove test_agent_config_loads_from_default_tools_without_tool_registry - This test assumes strands_tools is available which causes CI failures - Other tests adequately cover AgentConfig functionality 🤖 Assisted by Amazon Q Developer * test: add back tests with proper mocking for strands_tools - Add back test_agent_config_tools_without_tool_registry_error with mocking - Add back test_agent_config_loads_from_default_tools_without_tool_registry with mocking - Mock _create_default_tool_registry to avoid dependency on strands_tools - Add tool import for creating mock tools in tests - All 15 tests now pass without external dependencies 🤖 Assisted by Amazon Q Developer * test: fix Windows compatibility for file prefix test - Use platform-specific tempfile handling in test_agent_config_file_prefix_valid - Use mkstemp() with explicit cleanup on Windows for better permission handling - Keep NamedTemporaryFile on non-Windows platforms for simplicity - Should resolve permission errors on Windows GitHub runners 🤖 Assisted by Amazon Q Developer * refactor: replace AgentConfig class with config_to_agent function BREAKING CHANGE: Replace class-based AgentConfig with function-based config_to_agent - Replace AgentConfig class with config_to_agent function for simpler interface - Remove ToolRegistry dependency - let Agent handle tool loading internally - Remove DEFAULT_TOOLS concept and raise_exception_on_missing_tool parameter - Support both file paths and dictionary inputs with file:// prefix handling - Only pass non-None config values to Agent constructor (use Agent defaults) - Update experimental module exports to expose config_to_agent function - Rewrite all tests to use new function-based interface - Simplify tool handling by delegating to Agent class New interface: from strands.experimental import config_to_agent agent = config_to_agent('/path/to/config.json') Previous interface (removed): from strands.experimental.agent_config import AgentConfig config = AgentConfig('/path/to/config.json') agent = config.to_agent() 🤖 Assisted by Amazon Q Developer * feat: limit config_to_agent to core configuration keys - Remove support for advanced Agent parameters in config_to_agent - Only support: model, prompt, tools, name in configuration - Advanced parameters can still be passed via kwargs - Remove agent_id test and update function mapping - Keep interface simple and focused on basic agent configuration 🤖 Assisted by Amazon Q Developer * fix: use native Python typing instead of typing module - Replace Union[str, Dict[str, Any]] with str | dict[str, any] - Remove typing module imports - Use modern Python 3.10+ native typing syntax 🤖 Assisted by Amazon Q Developer * test: simplify file prefix test with proper context manager - Use NamedTemporaryFile with delete=True for automatic cleanup - Remove manual os.unlink call and try/finally block - Keep file operation within single context manager scope - Add f.flush() to ensure data is written before reading 🤖 Assisted by Amazon Q Developer * feat: add JSON schema validation to config_to_agent - Add jsonschema dependency for configuration validation - Implement JSON schema based on supported configuration keys - Provide detailed validation error messages with field paths - Add validation tests for invalid fields, types, and tool items - Support null values for optional fields (model, prompt, name) - Reject additional properties not in the schema - All 14 tests passing including new validation tests 🤖 Assisted by Amazon Q Developer * refactor: move JSON schema to separate file - Extract agent configuration schema to schemas/agent-config-v1.json - Add _load_schema() function to load schema from file at runtime - Improve code readability by separating schema from Python logic - Enable schema reuse by other tools and documentation - Maintain all existing validation functionality and tests 🤖 Assisted by Amazon Q Developer * perf: use pre-compiled JSON schema validator - Create Draft7Validator instance at module level for better performance - Avoid loading and compiling schema on every validation call - Schema is loaded once at import time and validator is reused - Maintains all existing validation functionality and error messages - Standard best practice for jsonschema validation performance 🤖 Assisted by Amazon Q Developer * feat: add tool validation and clarify limitations - Move JSON schema back to inline variable for simplicity - Add comprehensive tool validation with helpful error messages - Validate tools can be loaded as files, modules, or @tool functions - Add clear documentation about code-based instantiation limitations - Update module docstring and function comments with usage patterns - Add test for tool validation error messages - Remove schemas directory (no longer needed) 🤖 Assisted by Amazon Q Developer * fix: improve tool validation error messages and add comprehensive tests - Fix error message for missing modules to be more descriptive - Remove redundant 'to properly import this tool' text from error messages - Add specific error messages for missing modules vs missing functions - Add unit tests for each error case: - Invalid tool (not file/module/@tool) - Missing module (module doesn't exist) - Missing function (function not found in existing module) - All 17 tests passing with better error coverage 🤖 Assisted by Amazon Q Developer * fix: reference module instead of tool in error message - Change error message from 'Tool X not found' to 'Module X not found' - More accurate since we're trying to import it as a module at this point - Maintains existing test compatibility and error handling logic 🤖 Assisted by Amazon Q Developer * revert: change error message back to reference tool - Revert previous change from 'Module X not found' back to 'Tool X not found' - Keep original error message format as requested 🤖 Assisted by Amazon Q Developer * feat: use agent tool loading logic * fix: address pr comments --------- Co-authored-by: Matt Lee <[email protected]> Co-authored-by: Nicholas Clegg <[email protected]>
1 parent 3a7af77 commit b69478b

File tree

8 files changed

+343
-1
lines changed

8 files changed

+343
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ __pycache__*
1111
.vscode
1212
dist
1313
repl_state
14-
.kiro
14+
.kiro
15+
uv.lock

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
"boto3>=1.26.0,<2.0.0",
3131
"botocore>=1.29.0,<2.0.0",
3232
"docstring_parser>=0.15,<1.0",
33+
"jsonschema>=4.0.0,<5.0.0",
3334
"mcp>=1.11.0,<2.0.0",
3435
"pydantic>=2.4.0,<3.0.0",
3536
"typing-extensions>=4.13.2,<5.0.0",

src/strands/experimental/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
33
This module implements experimental features that are subject to change in future revisions without notice.
44
"""
5+
6+
from .agent_config import config_to_agent
7+
8+
__all__ = ["config_to_agent"]
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Experimental agent configuration utilities.
2+
3+
This module provides utilities for creating agents from configuration files or dictionaries.
4+
5+
Note: Configuration-based agent setup only works for tools that don't require code-based
6+
instantiation. For tools that need constructor arguments or complex setup, use the
7+
programmatic approach after creating the agent:
8+
9+
agent = config_to_agent("config.json")
10+
# Add tools that need code-based instantiation
11+
agent.tool_registry.process_tools([ToolWithConfigArg(HttpsConnection("localhost"))])
12+
"""
13+
14+
import json
15+
from pathlib import Path
16+
from typing import Any
17+
18+
import jsonschema
19+
from jsonschema import ValidationError
20+
21+
from ..agent import Agent
22+
23+
# JSON Schema for agent configuration
24+
AGENT_CONFIG_SCHEMA = {
25+
"$schema": "http://json-schema.org/draft-07/schema#",
26+
"title": "Agent Configuration",
27+
"description": "Configuration schema for creating agents",
28+
"type": "object",
29+
"properties": {
30+
"name": {"description": "Name of the agent", "type": ["string", "null"], "default": None},
31+
"model": {
32+
"description": "The model ID to use for this agent. If not specified, uses the default model.",
33+
"type": ["string", "null"],
34+
"default": None,
35+
},
36+
"prompt": {
37+
"description": "The system prompt for the agent. Provides high level context to the agent.",
38+
"type": ["string", "null"],
39+
"default": None,
40+
},
41+
"tools": {
42+
"description": "List of tools the agent can use. Can be file paths, "
43+
"Python module names, or @tool annotated functions in files.",
44+
"type": "array",
45+
"items": {"type": "string"},
46+
"default": [],
47+
},
48+
},
49+
"additionalProperties": False,
50+
}
51+
52+
# Pre-compile validator for better performance
53+
_VALIDATOR = jsonschema.Draft7Validator(AGENT_CONFIG_SCHEMA)
54+
55+
56+
def config_to_agent(config: str | dict[str, Any], **kwargs: dict[str, Any]) -> Agent:
57+
"""Create an Agent from a configuration file or dictionary.
58+
59+
This function supports tools that can be loaded declaratively (file paths, module names,
60+
or @tool annotated functions). For tools requiring code-based instantiation with constructor
61+
arguments, add them programmatically after creating the agent:
62+
63+
agent = config_to_agent("config.json")
64+
agent.process_tools([ToolWithConfigArg(HttpsConnection("localhost"))])
65+
66+
Args:
67+
config: Either a file path (with optional file:// prefix) or a configuration dictionary
68+
**kwargs: Additional keyword arguments to pass to the Agent constructor
69+
70+
Returns:
71+
Agent: A configured Agent instance
72+
73+
Raises:
74+
FileNotFoundError: If the configuration file doesn't exist
75+
json.JSONDecodeError: If the configuration file contains invalid JSON
76+
ValueError: If the configuration is invalid or tools cannot be loaded
77+
78+
Examples:
79+
Create agent from file:
80+
>>> agent = config_to_agent("/path/to/config.json")
81+
82+
Create agent from file with file:// prefix:
83+
>>> agent = config_to_agent("file:///path/to/config.json")
84+
85+
Create agent from dictionary:
86+
>>> config = {"model": "anthropic.claude-3-5-sonnet-20241022-v2:0", "tools": ["calculator"]}
87+
>>> agent = config_to_agent(config)
88+
"""
89+
# Parse configuration
90+
if isinstance(config, str):
91+
# Handle file path
92+
file_path = config
93+
94+
# Remove file:// prefix if present
95+
if file_path.startswith("file://"):
96+
file_path = file_path[7:]
97+
98+
# Load JSON from file
99+
config_path = Path(file_path)
100+
if not config_path.exists():
101+
raise FileNotFoundError(f"Configuration file not found: {file_path}")
102+
103+
with open(config_path, "r") as f:
104+
config_dict = json.load(f)
105+
elif isinstance(config, dict):
106+
config_dict = config.copy()
107+
else:
108+
raise ValueError("Config must be a file path string or dictionary")
109+
110+
# Validate configuration against schema
111+
try:
112+
_VALIDATOR.validate(config_dict)
113+
except ValidationError as e:
114+
# Provide more detailed error message
115+
error_path = " -> ".join(str(p) for p in e.absolute_path) if e.absolute_path else "root"
116+
raise ValueError(f"Configuration validation error at {error_path}: {e.message}") from e
117+
118+
# Prepare Agent constructor arguments
119+
agent_kwargs = {}
120+
121+
# Map configuration keys to Agent constructor parameters
122+
config_mapping = {
123+
"model": "model",
124+
"prompt": "system_prompt",
125+
"tools": "tools",
126+
"name": "name",
127+
}
128+
129+
# Only include non-None values from config
130+
for config_key, agent_param in config_mapping.items():
131+
if config_key in config_dict and config_dict[config_key] is not None:
132+
agent_kwargs[agent_param] = config_dict[config_key]
133+
134+
# Override with any additional kwargs provided
135+
agent_kwargs.update(kwargs)
136+
137+
# Create and return Agent
138+
return Agent(**agent_kwargs)
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""Tests for experimental config_to_agent function."""
2+
3+
import json
4+
import os
5+
import tempfile
6+
7+
import pytest
8+
9+
from strands.experimental import config_to_agent
10+
11+
12+
def test_config_to_agent_with_dict():
13+
"""Test config_to_agent can be created with dict config."""
14+
config = {"model": "test-model"}
15+
agent = config_to_agent(config)
16+
assert agent.model.config["model_id"] == "test-model"
17+
18+
19+
def test_config_to_agent_with_system_prompt():
20+
"""Test config_to_agent handles system prompt correctly."""
21+
config = {"model": "test-model", "prompt": "Test prompt"}
22+
agent = config_to_agent(config)
23+
assert agent.system_prompt == "Test prompt"
24+
25+
26+
def test_config_to_agent_with_tools_list():
27+
"""Test config_to_agent handles tools list without failing."""
28+
# Use a simple test that doesn't require actual tool loading
29+
config = {"model": "test-model", "tools": []}
30+
agent = config_to_agent(config)
31+
assert agent.model.config["model_id"] == "test-model"
32+
33+
34+
def test_config_to_agent_with_kwargs_override():
35+
"""Test that kwargs can override config values."""
36+
config = {"model": "test-model", "prompt": "Config prompt"}
37+
agent = config_to_agent(config, system_prompt="Override prompt")
38+
assert agent.system_prompt == "Override prompt"
39+
40+
41+
def test_config_to_agent_file_prefix_required():
42+
"""Test that file paths without file:// prefix work."""
43+
import json
44+
import tempfile
45+
46+
config_data = {"model": "test-model"}
47+
temp_path = ""
48+
49+
# We need to create files like this for windows compatibility
50+
try:
51+
with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f:
52+
json.dump(config_data, f)
53+
f.flush()
54+
temp_path = f.name
55+
56+
agent = config_to_agent(temp_path)
57+
assert agent.model.config["model_id"] == "test-model"
58+
finally:
59+
# Clean up the temporary file
60+
if os.path.exists(temp_path):
61+
os.remove(temp_path)
62+
63+
64+
def test_config_to_agent_file_prefix_valid():
65+
"""Test that file:// prefix is properly handled."""
66+
config_data = {"model": "test-model", "prompt": "Test prompt"}
67+
temp_path = ""
68+
69+
try:
70+
with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f:
71+
json.dump(config_data, f)
72+
f.flush()
73+
temp_path = f.name
74+
75+
agent = config_to_agent(f"file://{temp_path}")
76+
assert agent.model.config["model_id"] == "test-model"
77+
assert agent.system_prompt == "Test prompt"
78+
finally:
79+
# Clean up the temporary file
80+
if os.path.exists(temp_path):
81+
os.remove(temp_path)
82+
83+
84+
def test_config_to_agent_file_not_found():
85+
"""Test that FileNotFoundError is raised for missing files."""
86+
with pytest.raises(FileNotFoundError, match="Configuration file not found"):
87+
config_to_agent("/nonexistent/path/config.json")
88+
89+
90+
def test_config_to_agent_invalid_json():
91+
"""Test that JSONDecodeError is raised for invalid JSON."""
92+
try:
93+
with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as f:
94+
f.write("invalid json content")
95+
temp_path = f.name
96+
97+
with pytest.raises(json.JSONDecodeError):
98+
config_to_agent(temp_path)
99+
finally:
100+
# Clean up the temporary file
101+
if os.path.exists(temp_path):
102+
os.remove(temp_path)
103+
104+
105+
def test_config_to_agent_invalid_config_type():
106+
"""Test that ValueError is raised for invalid config types."""
107+
with pytest.raises(ValueError, match="Config must be a file path string or dictionary"):
108+
config_to_agent(123)
109+
110+
111+
def test_config_to_agent_with_name():
112+
"""Test config_to_agent handles agent name."""
113+
config = {"model": "test-model", "name": "TestAgent"}
114+
agent = config_to_agent(config)
115+
assert agent.name == "TestAgent"
116+
117+
118+
def test_config_to_agent_ignores_none_values():
119+
"""Test that None values in config are ignored."""
120+
config = {"model": "test-model", "prompt": None, "name": None}
121+
agent = config_to_agent(config)
122+
assert agent.model.config["model_id"] == "test-model"
123+
# Agent should use its defaults for None values
124+
125+
126+
def test_config_to_agent_validation_error_invalid_field():
127+
"""Test that invalid fields raise validation errors."""
128+
config = {"model": "test-model", "invalid_field": "value"}
129+
with pytest.raises(ValueError, match="Configuration validation error"):
130+
config_to_agent(config)
131+
132+
133+
def test_config_to_agent_validation_error_wrong_type():
134+
"""Test that wrong field types raise validation errors."""
135+
config = {"model": "test-model", "tools": "not-a-list"}
136+
with pytest.raises(ValueError, match="Configuration validation error"):
137+
config_to_agent(config)
138+
139+
140+
def test_config_to_agent_validation_error_invalid_tool_item():
141+
"""Test that invalid tool items raise validation errors."""
142+
config = {"model": "test-model", "tools": ["valid-tool", 123]}
143+
with pytest.raises(ValueError, match="Configuration validation error"):
144+
config_to_agent(config)
145+
146+
147+
def test_config_to_agent_validation_error_invalid_tool():
148+
"""Test that invalid tools raise helpful error messages."""
149+
config = {"model": "test-model", "tools": ["nonexistent_tool"]}
150+
with pytest.raises(ValueError, match="Failed to load tool nonexistent_tool"):
151+
config_to_agent(config)
152+
153+
154+
def test_config_to_agent_validation_error_missing_module():
155+
"""Test that missing modules raise helpful error messages."""
156+
config = {"model": "test-model", "tools": ["nonexistent.module.tool"]}
157+
with pytest.raises(ValueError, match="Failed to load tool nonexistent.module.tool"):
158+
config_to_agent(config)
159+
160+
161+
def test_config_to_agent_validation_error_missing_function():
162+
"""Test that missing functions in existing modules raise helpful error messages."""
163+
config = {"model": "test-model", "tools": ["json.nonexistent_function"]}
164+
with pytest.raises(ValueError, match="Failed to load tool json.nonexistent_function"):
165+
config_to_agent(config)
166+
167+
168+
def test_config_to_agent_with_tool():
169+
"""Test that missing functions in existing modules raise helpful error messages."""
170+
config = {"model": "test-model", "tools": ["tests.fixtures.say_tool:say"]}
171+
agent = config_to_agent(config)
172+
assert "say" in agent.tool_names

tests_integ/fixtures/say_tool.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from strands import tool
2+
3+
4+
@tool
5+
def say(input: str) -> str:
6+
"""Say the input"""
7+
return f"Said: {input}"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"model": "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
3+
"tools": ["tests_integ.fixtures.say_tool:say"],
4+
"prompt": "You use the say tool to communicate",
5+
"name": "Sayer"
6+
}

tests_integ/test_agent_json.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from strands.experimental import config_to_agent
2+
3+
4+
def test_load_agent_from_config():
5+
agent = config_to_agent("file://tests_integ/fixtures/test_agent.json")
6+
7+
result = agent("Say hello")
8+
9+
assert "Sayer" == agent.name
10+
assert "You use the say tool to communicate" == agent.system_prompt
11+
assert agent.tool_names[0] == "say"
12+
assert agent.model.get_config().get("model_id") == "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
13+
assert "hello" in str(result).lower()

0 commit comments

Comments
 (0)