-
Notifications
You must be signed in to change notification settings - Fork 420
feat: add experimental AgentConfig with comprehensive tool management #935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6bbe313
b5a360a
ccec79c
13f25fd
280f85a
f4ed508
f372666
c2d1baa
d49782e
3b4f9fd
2f5d692
874c42f
383b18b
a051bef
953ae34
811afab
da9ff2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,4 +11,5 @@ __pycache__* | |
.vscode | ||
dist | ||
repl_state | ||
.kiro | ||
.kiro | ||
uv.lock |
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: | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""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") | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
agent.process_tools([ToolWithConfigArg(HttpsConnection("localhost"))]) | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# 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", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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: | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""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") | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
Unshure marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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) |
Uh oh!
There was an error while loading. Please reload this page.