Skip to content

Commit 16e6b24

Browse files
committed
Add agent configuration support with JSON/dict configs and tool name resolution
- Add AgentConfig class for loading configuration from JSON files or dicts - Support model, prompt, and tools configuration options - Update tool registry to resolve tool names from strands_tools package - Maintain backward compatibility with existing Agent constructor - Add comprehensive tests for configuration loading and validation - Fix all linting, type checking, and formatting issues
1 parent 54bc162 commit 16e6b24

File tree

4 files changed

+342
-1
lines changed

4 files changed

+342
-1
lines changed

src/strands/agent/agent.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from ..types.tools import ToolResult, ToolUse
5858
from ..types.traces import AttributeValue
5959
from .agent_result import AgentResult
60+
from .config import AgentConfig
6061
from .conversation_manager import (
6162
ConversationManager,
6263
SlidingWindowConversationManager,
@@ -218,6 +219,7 @@ def __init__(
218219
load_tools_from_directory: bool = False,
219220
trace_attributes: Optional[Mapping[str, AttributeValue]] = None,
220221
*,
222+
config: Optional[Union[str, dict[str, Any]]] = None,
221223
agent_id: Optional[str] = None,
222224
name: Optional[str] = None,
223225
description: Optional[str] = None,
@@ -255,6 +257,9 @@ def __init__(
255257
load_tools_from_directory: Whether to load and automatically reload tools in the `./tools/` directory.
256258
Defaults to False.
257259
trace_attributes: Custom trace attributes to apply to the agent's trace span.
260+
config: Path to agent configuration file (JSON) or configuration dictionary.
261+
Supports agent-format.md specification with fields: tools, model, prompt.
262+
Constructor parameters override config file values when both are provided.
258263
agent_id: Optional ID for the agent, useful for session management and multi-agent scenarios.
259264
Defaults to "default".
260265
name: name of the Agent
@@ -272,6 +277,24 @@ def __init__(
272277
Raises:
273278
ValueError: If agent id contains path separators.
274279
"""
280+
# Load configuration if provided and merge with constructor parameters
281+
# Constructor parameters take precedence over config file values
282+
if config is not None:
283+
try:
284+
agent_config = AgentConfig(config)
285+
286+
# Apply config values only if constructor parameters are None
287+
if model is None:
288+
model = agent_config.model
289+
if tools is None:
290+
config_tools = agent_config.tools
291+
if config_tools is not None:
292+
tools = list(config_tools) # Cast List[str] to list[Union[str, dict[str, str], Any]]
293+
if system_prompt is None:
294+
system_prompt = agent_config.system_prompt
295+
except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
296+
raise ValueError(f"Failed to load agent configuration: {e}") from e
297+
275298
self.model = BedrockModel() if not model else BedrockModel(model_id=model) if isinstance(model, str) else model
276299
self.messages = messages if messages is not None else []
277300

src/strands/agent/config.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Agent configuration parser for agent-format.md support."""
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any, Dict, List, Optional, Union
6+
7+
8+
class AgentConfig:
9+
"""Parser for agent configuration files following agent-format.md specification."""
10+
11+
def __init__(self, config_source: Union[str, Dict[str, Any]]):
12+
"""Initialize agent configuration.
13+
14+
Args:
15+
config_source: Path to JSON config file or config dictionary
16+
"""
17+
if isinstance(config_source, str):
18+
self.config = self._load_from_file(config_source)
19+
elif isinstance(config_source, dict):
20+
self.config = config_source.copy()
21+
else:
22+
raise ValueError("config_source must be a file path string or dictionary")
23+
24+
def _load_from_file(self, file_path: str) -> Dict[str, Any]:
25+
"""Load configuration from JSON file.
26+
27+
Args:
28+
file_path: Path to the configuration file
29+
30+
Returns:
31+
Parsed configuration dictionary
32+
33+
Raises:
34+
FileNotFoundError: If config file doesn't exist
35+
json.JSONDecodeError: If config file contains invalid JSON
36+
"""
37+
path = Path(file_path).expanduser().resolve()
38+
39+
if not path.exists():
40+
raise FileNotFoundError(f"Agent config file not found: {file_path}")
41+
42+
try:
43+
with open(path, "r", encoding="utf-8") as f:
44+
data = json.load(f)
45+
if not isinstance(data, dict):
46+
raise ValueError(f"Config file {file_path} must contain a JSON object")
47+
return data
48+
except json.JSONDecodeError as e:
49+
raise json.JSONDecodeError(f"Invalid JSON in config file {file_path}: {e.msg}", e.doc, e.pos) from e
50+
51+
@property
52+
def tools(self) -> Optional[List[str]]:
53+
"""Get tools configuration."""
54+
return self.config.get("tools")
55+
56+
@property
57+
def model(self) -> Optional[str]:
58+
"""Get model configuration."""
59+
return self.config.get("model")
60+
61+
@property
62+
def system_prompt(self) -> Optional[str]:
63+
"""Get system prompt from 'prompt' field."""
64+
return self.config.get("prompt")

src/strands/tools/registry.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,23 @@ def process_tools(self, tools: List[Any]) -> List[str]:
5555
tool_names = []
5656

5757
def add_tool(tool: Any) -> None:
58-
# Case 1: String file path
58+
# Case 1: String - could be file path or tool name from strands_tools
5959
if isinstance(tool, str):
60+
# First try to import from strands_tools.{tool_name}
61+
try:
62+
import importlib
63+
64+
module_name = f"strands_tools.{tool}"
65+
tool_module = importlib.import_module(module_name)
66+
if hasattr(tool_module, tool):
67+
tool_obj = getattr(tool_module, tool)
68+
# Recursively process the imported tool object
69+
add_tool(tool_obj)
70+
return
71+
except (ImportError, AttributeError):
72+
pass
73+
74+
# If not found in strands_tools, treat as file path
6075
# Extract tool name from path
6176
tool_name = os.path.basename(tool).split(".")[0]
6277
self.load_tool_from_filepath(tool_name=tool_name, tool_path=tool)
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Tests for agent configuration functionality."""
2+
3+
import json
4+
import tempfile
5+
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
10+
from strands.agent import Agent
11+
from strands.agent.config import AgentConfig
12+
13+
14+
class TestBackwardCompatibility:
15+
"""Test that existing Agent usage continues to work."""
16+
17+
def test_existing_agent_usage_still_works(self):
18+
"""Test that Agent can be created without config parameter."""
19+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock:
20+
# This should work exactly as before - no config parameter
21+
agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0", system_prompt="You are helpful")
22+
23+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-3-haiku-20240307-v1:0")
24+
assert agent.system_prompt == "You are helpful"
25+
assert agent.name == "Strands Agents" # Default name
26+
27+
28+
class TestAgentConfig:
29+
"""Test AgentConfig class functionality."""
30+
31+
def test_load_from_dict(self):
32+
"""Test loading config from dictionary."""
33+
config_dict = {
34+
"tools": ["calculator", "shell"],
35+
"model": "us.anthropic.claude-sonnet-4-20250514-v1:0",
36+
"prompt": "You are a helpful assistant",
37+
}
38+
39+
config = AgentConfig(config_dict)
40+
41+
assert config.tools == ["calculator", "shell"]
42+
assert config.model == "us.anthropic.claude-sonnet-4-20250514-v1:0"
43+
assert config.system_prompt == "You are a helpful assistant"
44+
45+
def test_load_from_file(self):
46+
"""Test loading config from JSON file."""
47+
config_dict = {
48+
"tools": ["./tools/shell_tool.py"],
49+
"model": "us.anthropic.claude-3-haiku-20240307-v1:0",
50+
"prompt": "You are a coding assistant",
51+
}
52+
53+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
54+
json.dump(config_dict, f)
55+
temp_path = f.name
56+
57+
try:
58+
config = AgentConfig(temp_path)
59+
60+
assert config.tools == ["./tools/shell_tool.py"]
61+
assert config.model == "us.anthropic.claude-3-haiku-20240307-v1:0"
62+
assert config.system_prompt == "You are a coding assistant"
63+
finally:
64+
Path(temp_path).unlink()
65+
66+
def test_missing_file_error(self):
67+
"""Test error handling for missing config file."""
68+
with pytest.raises(FileNotFoundError, match="Agent config file not found"):
69+
AgentConfig("/nonexistent/path/config.json")
70+
71+
def test_invalid_json_error(self):
72+
"""Test error handling for invalid JSON."""
73+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
74+
f.write("{ invalid json }")
75+
temp_path = f.name
76+
77+
try:
78+
with pytest.raises(json.JSONDecodeError, match="Invalid JSON in config file"):
79+
AgentConfig(temp_path)
80+
finally:
81+
Path(temp_path).unlink()
82+
83+
def test_invalid_config_source_type(self):
84+
"""Test error handling for invalid config source type."""
85+
with pytest.raises(ValueError, match="config_source must be a file path string or dictionary"):
86+
AgentConfig(123)
87+
88+
def test_missing_fields(self):
89+
"""Test handling of missing configuration fields."""
90+
config = AgentConfig({})
91+
92+
assert config.tools is None
93+
assert config.model is None
94+
assert config.system_prompt is None
95+
96+
97+
class TestAgentWithConfig:
98+
"""Test Agent class with configuration support."""
99+
100+
def test_agent_with_config_dict(self):
101+
"""Test Agent initialization with config dictionary."""
102+
# Mock the strands_tools import
103+
mock_tool = MagicMock()
104+
mock_tool.name = "file_read"
105+
mock_tool.spec = {"name": "file_read", "description": "Mock file read tool"}
106+
107+
config = {
108+
"tools": ["file_read"],
109+
"model": "us.anthropic.claude-sonnet-4-20250514-v1:0",
110+
"prompt": "You are helpful",
111+
}
112+
113+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock, patch("importlib.import_module") as mock_import:
114+
# Mock the strands_tools.file_read module
115+
mock_module = MagicMock()
116+
mock_module.file_read = mock_tool
117+
118+
def side_effect(module_name):
119+
if module_name == "strands_tools.file_read":
120+
return mock_module
121+
raise ImportError(f"No module named '{module_name}'")
122+
123+
mock_import.side_effect = side_effect
124+
125+
agent = Agent(config=config)
126+
127+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-sonnet-4-20250514-v1:0")
128+
assert agent.system_prompt == "You are helpful"
129+
assert len(agent.tool_registry.get_all_tool_specs()) == 1
130+
131+
def test_agent_with_config_file(self):
132+
"""Test Agent initialization with config file."""
133+
# Mock the strands_tools import
134+
mock_tool = MagicMock()
135+
mock_tool.name = "shell"
136+
mock_tool.spec = {"name": "shell", "description": "Mock shell tool"}
137+
138+
config_dict = {
139+
"tools": ["shell"],
140+
"model": "us.anthropic.claude-3-haiku-20240307-v1:0",
141+
"prompt": "You are a coding assistant",
142+
}
143+
144+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
145+
json.dump(config_dict, f)
146+
temp_path = f.name
147+
148+
try:
149+
with (
150+
patch("strands.agent.agent.BedrockModel") as mock_bedrock,
151+
patch("importlib.import_module") as mock_import,
152+
):
153+
# Mock the strands_tools.shell module
154+
mock_module = MagicMock()
155+
mock_module.shell = mock_tool
156+
mock_import.return_value = mock_module
157+
158+
agent = Agent(config=temp_path)
159+
160+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-3-haiku-20240307-v1:0")
161+
assert agent.system_prompt == "You are a coding assistant"
162+
assert len(agent.tool_registry.get_all_tool_specs()) == 1
163+
finally:
164+
Path(temp_path).unlink()
165+
166+
def test_constructor_params_override_config(self):
167+
"""Test that constructor parameters override config values."""
168+
# Mock the strands_tools import
169+
mock_tool = MagicMock()
170+
mock_tool.name = "file_read"
171+
mock_tool.spec = {"name": "file_read", "description": "Mock file read tool"}
172+
173+
config = {
174+
"tools": ["file_read"],
175+
"model": "us.anthropic.claude-sonnet-4-20250514-v1:0",
176+
"prompt": "Config prompt",
177+
}
178+
179+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock, patch("importlib.import_module") as mock_import:
180+
# Mock the strands_tools.file_read module
181+
mock_module = MagicMock()
182+
mock_module.file_read = mock_tool
183+
mock_import.return_value = mock_module
184+
185+
agent = Agent(
186+
config=config, model="us.anthropic.claude-3-haiku-20240307-v1:0", system_prompt="Constructor prompt"
187+
)
188+
189+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-3-haiku-20240307-v1:0")
190+
assert agent.system_prompt == "Constructor prompt"
191+
assert len(agent.tool_registry.get_all_tool_specs()) == 1
192+
193+
def test_config_values_used_when_constructor_params_none(self):
194+
"""Test that config values are used when constructor parameters are None."""
195+
# Mock the strands_tools import
196+
mock_tool = MagicMock()
197+
mock_tool.name = "file_write"
198+
mock_tool.spec = {"name": "file_write", "description": "Mock file write tool"}
199+
200+
config = {
201+
"tools": ["file_write"],
202+
"model": "us.anthropic.claude-sonnet-4-20250514-v1:0",
203+
"prompt": "Config prompt",
204+
}
205+
206+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock, patch("importlib.import_module") as mock_import:
207+
# Mock the strands_tools.file_write module
208+
mock_module = MagicMock()
209+
mock_module.file_write = mock_tool
210+
mock_import.return_value = mock_module
211+
212+
agent = Agent(config=config, model=None, system_prompt=None)
213+
214+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-sonnet-4-20250514-v1:0")
215+
assert agent.system_prompt == "Config prompt"
216+
assert len(agent.tool_registry.get_all_tool_specs()) == 1
217+
218+
def test_agent_without_config(self):
219+
"""Test that Agent works normally without config parameter."""
220+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock:
221+
agent = Agent(model="us.anthropic.claude-3-haiku-20240307-v1:0", system_prompt="Test prompt")
222+
223+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-3-haiku-20240307-v1:0")
224+
assert agent.system_prompt == "Test prompt"
225+
226+
def test_config_error_handling(self):
227+
"""Test error handling for invalid config."""
228+
with pytest.raises(ValueError, match="Failed to load agent configuration"):
229+
Agent(config="/nonexistent/config.json")
230+
231+
def test_partial_config(self):
232+
"""Test Agent with partial config (only some fields specified)."""
233+
config = {"model": "us.anthropic.claude-sonnet-4-20250514-v1:0"}
234+
235+
with patch("strands.agent.agent.BedrockModel") as mock_bedrock:
236+
agent = Agent(config=config, system_prompt="Constructor prompt")
237+
238+
mock_bedrock.assert_called_with(model_id="us.anthropic.claude-sonnet-4-20250514-v1:0")
239+
assert agent.system_prompt == "Constructor prompt"

0 commit comments

Comments
 (0)