feat: add support for custom code tools and enhance error handling
This commit is contained in:
@@ -77,7 +77,18 @@ class AgentFactory:
|
||||
return self.agents_cache[name]
|
||||
|
||||
try:
|
||||
agent_config = self.config.get("agents", {}).get(name)
|
||||
agents_config = self.config.get("agents", {})
|
||||
agent_config = None
|
||||
|
||||
# Handle both dictionary and list formats for the agents section.
|
||||
if isinstance(agents_config, dict):
|
||||
agent_config = agents_config.get(name)
|
||||
elif isinstance(agents_config, list):
|
||||
for cfg in agents_config:
|
||||
if isinstance(cfg, dict) and cfg.get("name") == name:
|
||||
agent_config = cfg
|
||||
break
|
||||
|
||||
if not agent_config:
|
||||
raise AgentCreationError(f"Agent '{name}' not found in configuration")
|
||||
|
||||
|
||||
+119
-10
@@ -130,6 +130,8 @@ class WebSearchTool(Tool):
|
||||
cx (str): The Programmable Search Engine ID.
|
||||
"""
|
||||
|
||||
code: str
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize a new WebSearchTool instance.
|
||||
@@ -266,6 +268,70 @@ class WebSearchTool(Tool):
|
||||
)
|
||||
|
||||
|
||||
class CustomCodeTool(Tool):
|
||||
"""
|
||||
A tool that executes custom Python code from the configuration.
|
||||
|
||||
The code is provided in the agent's configuration and is executed using `exec()`.
|
||||
The executed code has access to `input_data` (the tool input) and `context`
|
||||
(the current routing context) variables. The code must assign its output to a
|
||||
variable named `result`.
|
||||
|
||||
.. warning::
|
||||
This tool executes arbitrary Python code from the configuration.
|
||||
Ensure that the code is from a trusted source, as it can execute any
|
||||
system-level operations.
|
||||
|
||||
Configuration:
|
||||
name (str): The name of the tool.
|
||||
description (str): A description of what the tool does.
|
||||
code (str): The Python code snippet to execute.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initializes the CustomCodeTool."""
|
||||
name = config.get("name")
|
||||
if not name:
|
||||
raise ConfigurationError("Custom code tool is missing a 'name'.")
|
||||
description = config.get(
|
||||
"description", f"Executes custom code for tool '{name}'."
|
||||
)
|
||||
super().__init__(name, description, config)
|
||||
|
||||
code_val = config.get("code")
|
||||
if not code_val or not isinstance(code_val, str):
|
||||
raise ConfigurationError(
|
||||
f"Custom code tool '{name}' is missing 'code' in its configuration or it is not a string."
|
||||
)
|
||||
self.code = code_val
|
||||
|
||||
async def execute(
|
||||
self, input_data: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Executes the custom Python code."""
|
||||
execution_scope = {
|
||||
"input_data": input_data,
|
||||
"context": context or {},
|
||||
"result": None,
|
||||
# Provide some common modules for convenience
|
||||
"json": json,
|
||||
"re": re,
|
||||
"os": os,
|
||||
}
|
||||
try:
|
||||
exec(self.code, {}, execution_scope)
|
||||
result_val = execution_scope.get("result")
|
||||
return str(result_val) if result_val is not None else ""
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error executing custom code for tool '{self.name}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise ExecutionError(
|
||||
f"Error executing custom code for tool '{self.name}': {e}"
|
||||
) from e
|
||||
|
||||
|
||||
class ToolAgent(Agent):
|
||||
"""
|
||||
ToolAgent represents an agent that interfaces with external tools.
|
||||
@@ -312,15 +378,44 @@ class ToolAgent(Agent):
|
||||
|
||||
for tool_config in tool_configs:
|
||||
try:
|
||||
if isinstance(tool_config, str):
|
||||
# Handle string-based configurations that might be JSON, a Python
|
||||
# literal, or even a doubly-encoded JSON string. We progressively
|
||||
# attempt to parse the value, falling back gracefully so that
|
||||
# downstream validation can raise clear errors when needed.
|
||||
parsed_config = None
|
||||
try:
|
||||
# First, try straightforward JSON decoding.
|
||||
parsed_config = json.loads(tool_config)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
import ast
|
||||
|
||||
# Next, attempt to evaluate the string as a Python literal.
|
||||
evaluated = ast.literal_eval(tool_config)
|
||||
# If the literal is itself a string, it may still be JSON
|
||||
# encoded—handle that case as well.
|
||||
if isinstance(evaluated, str):
|
||||
parsed_config = json.loads(evaluated)
|
||||
else:
|
||||
parsed_config = evaluated
|
||||
except (ValueError, SyntaxError, json.JSONDecodeError):
|
||||
# If all parsing attempts fail, leave parsed_config as None
|
||||
# and allow the subsequent validation logic to surface an
|
||||
# appropriate ConfigurationError.
|
||||
pass
|
||||
if parsed_config is not None:
|
||||
tool_config = parsed_config
|
||||
|
||||
if not isinstance(tool_config, dict) or "name" not in tool_config:
|
||||
raise ValueError(
|
||||
raise ConfigurationError(
|
||||
"Each tool configuration must be a dictionary with a 'name' key. "
|
||||
f"Invalid configuration found: {tool_config}"
|
||||
)
|
||||
|
||||
tool_name = tool_config.get("name")
|
||||
if not tool_name or not isinstance(tool_name, str):
|
||||
raise ValueError(
|
||||
raise ConfigurationError(
|
||||
"Tool 'name' must be a non-empty string. "
|
||||
f"Invalid configuration found: {tool_config}"
|
||||
)
|
||||
@@ -336,14 +431,18 @@ class ToolAgent(Agent):
|
||||
tool_specific_config = config_data.get("config", config_data)
|
||||
self.tools[tool_name] = WebSearchTool(tool_specific_config)
|
||||
logger.debug(f"Initialized web search tool for agent '{self.name}'")
|
||||
else:
|
||||
# Try to load a custom tool
|
||||
elif "code" in config_data:
|
||||
self.tools[tool_name] = CustomCodeTool(config_data)
|
||||
logger.debug(
|
||||
f"Initialized custom code tool '{tool_name}' for agent '{self.name}'"
|
||||
)
|
||||
elif "module" in config_data:
|
||||
# Try to load a custom tool defined in an external module
|
||||
module_name = config_data.get("module")
|
||||
if not module_name:
|
||||
raise ValueError(
|
||||
f"No module specified for custom tool '{tool_name}'"
|
||||
if not isinstance(module_name, str) or not module_name:
|
||||
raise ConfigurationError(
|
||||
f"Custom tool '{tool_name}' 'module' must be a non-empty string."
|
||||
)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
# Convert snake_case tool names to CamelCase to find the correct class
|
||||
@@ -358,11 +457,21 @@ class ToolAgent(Agent):
|
||||
f"Initialized custom tool '{tool_name}' for agent '{self.name}'"
|
||||
)
|
||||
except (ImportError, AttributeError) as e:
|
||||
raise ValueError(
|
||||
raise ConfigurationError(
|
||||
f"Failed to load tool '{tool_name}' from module '{module_name}': {str(e)}"
|
||||
)
|
||||
else:
|
||||
# Neither built-in nor properly configured custom tool
|
||||
raise ConfigurationError(
|
||||
f"Tool '{tool_name}' is not a built-in tool and is missing a 'code' or 'module' definition."
|
||||
)
|
||||
except Exception as e:
|
||||
raise AgentCreationError(f"Failed to initialize tool: {str(e)}")
|
||||
# Allow configuration-related issues to propagate so that callers
|
||||
# (and tests) can handle them explicitly. Wrap all other
|
||||
# unexpected errors in an AgentCreationError for context.
|
||||
if isinstance(e, ConfigurationError):
|
||||
raise
|
||||
raise AgentCreationError(f"Failed to initialize tool: {str(e)}") from e
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Feature: Custom Code Tool in ToolAgent
|
||||
As a developer,
|
||||
I want to define tools that execute custom Python code from the configuration,
|
||||
so that I can create flexible, dynamic tools without writing new classes.
|
||||
|
||||
Background:
|
||||
Given a TemplateRenderer is initialized
|
||||
|
||||
Scenario: Successfully execute a custom code tool
|
||||
Given a configuration for a ToolAgent with a custom code tool that concatenates input with a fixed string
|
||||
"""
|
||||
agents:
|
||||
- name: custom_tool_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: my_custom_tool
|
||||
description: "A custom tool that concatenates."
|
||||
code: |
|
||||
result = f"Custom code executed with: {input_data}"
|
||||
"""
|
||||
And I create the ToolAgent named "custom_tool_agent"
|
||||
When I process this message "my_custom_tool(hello world)" with the agent
|
||||
Then the response should be the following "Custom code executed with: hello world"
|
||||
|
||||
Scenario: Custom code tool accessing context
|
||||
Given a configuration for a ToolAgent with a custom code tool that accesses context
|
||||
"""
|
||||
agents:
|
||||
- name: context_tool_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: context_reader
|
||||
description: "A custom tool that reads from context."
|
||||
code: |
|
||||
suffix = context.get('suffix', 'default')
|
||||
result = f"{input_data}-{suffix}"
|
||||
"""
|
||||
And I create the ToolAgent named "context_tool_agent"
|
||||
When I process this message "context_reader(data)" with the agent with context
|
||||
"""
|
||||
{
|
||||
"suffix": "from_context"
|
||||
}
|
||||
"""
|
||||
Then the response should be the following "data-from_context"
|
||||
|
||||
Scenario: Custom code tool with no result assigned
|
||||
Given a configuration for a ToolAgent with a custom code tool that does not assign to result
|
||||
"""
|
||||
agents:
|
||||
- name: no_result_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: no_result_tool
|
||||
description: "A tool that does not assign to result."
|
||||
code: |
|
||||
x = 1 + 1 # Does something but no 'result = ...'
|
||||
"""
|
||||
And I create the ToolAgent named "no_result_agent"
|
||||
When I process this message "no_result_tool(any input)" with the agent
|
||||
Then the response should be the following ""
|
||||
|
||||
Scenario: Handle execution error in custom code tool
|
||||
Given a configuration for a ToolAgent with a custom code tool containing invalid Python code
|
||||
"""
|
||||
agents:
|
||||
- name: error_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: error_tool
|
||||
description: "A tool with an error."
|
||||
code: |
|
||||
result = 1 / 0
|
||||
"""
|
||||
And I create the ToolAgent named "error_agent"
|
||||
When I try to process the message "error_tool(trigger)" with the agent
|
||||
Then an ExecutionError should be raised with the message containing "division by zero"
|
||||
|
||||
Scenario Outline: Handle configuration errors for custom code tool
|
||||
Given a configuration for a ToolAgent with an invalid custom code tool config '<config_json>'
|
||||
When I try to create the ToolAgent named "bad_config_agent"
|
||||
Then a ConfigurationError should be raised with the message containing "<error_message>"
|
||||
|
||||
Examples:
|
||||
| config_json | error_message |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"description":"d","code":"c"}]}}]} | Each tool configuration must be a dictionary with a 'name' key |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"name":"t1","description":"d"}]}}]} | is not a built-in tool and is missing a 'code' or 'module' |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"name":"t1","code":123}]}}]} | is missing 'code' in its configuration or it is not a string |
|
||||
@@ -61,8 +61,16 @@ def after_scenario(context, scenario):
|
||||
# Capture any errors that occurred during the scenario
|
||||
if scenario.status == "failed":
|
||||
print(f"\n\n===== SCENARIO FAILED: {scenario.name} =====")
|
||||
if hasattr(context, "error"):
|
||||
print(f"Error: {context.error}")
|
||||
if hasattr(context, "error") and context.error:
|
||||
print(f"Exception Type: {type(context.error).__name__}")
|
||||
print(f"Exception Message: {str(context.error)}")
|
||||
if hasattr(context.error, "__traceback__"):
|
||||
print("--- Traceback ---")
|
||||
tb_lines = traceback.format_exception(
|
||||
type(context.error), context.error, context.error.__traceback__
|
||||
)
|
||||
print("".join(tb_lines), end="")
|
||||
print("-------------------")
|
||||
print("===============================\n")
|
||||
|
||||
if hasattr(context, "env_patchers"):
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import yaml
|
||||
from behave import given
|
||||
from behave import then
|
||||
from behave import when
|
||||
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
from cleveragents.core.config import ConfigurationManager
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
def _setup_context_from_text(context, config_text: str):
|
||||
"""Helper to load configuration from a string."""
|
||||
context.config_manager = ConfigurationManager()
|
||||
config = yaml.safe_load(config_text)
|
||||
context.config_manager.config = config
|
||||
if not hasattr(context, "template_renderer"):
|
||||
context.template_renderer = TemplateRenderer()
|
||||
context.agent_factory = AgentFactory(
|
||||
context.config_manager.to_dict(), context.template_renderer
|
||||
)
|
||||
|
||||
|
||||
@given("a TemplateRenderer is initialized")
|
||||
def step_init_renderer(context):
|
||||
context.template_renderer = TemplateRenderer()
|
||||
|
||||
|
||||
@given(
|
||||
"a configuration for a ToolAgent with a custom code tool that concatenates input with a fixed string"
|
||||
)
|
||||
def step_config_concat_tool(context):
|
||||
_setup_context_from_text(context, context.text)
|
||||
|
||||
|
||||
@given("a configuration for a ToolAgent with a custom code tool that accesses context")
|
||||
def step_config_context_tool(context):
|
||||
_setup_context_from_text(context, context.text)
|
||||
|
||||
|
||||
@given(
|
||||
"a configuration for a ToolAgent with a custom code tool that does not assign to result"
|
||||
)
|
||||
def step_config_no_result_tool(context):
|
||||
_setup_context_from_text(context, context.text)
|
||||
|
||||
|
||||
@given(
|
||||
"a configuration for a ToolAgent with a custom code tool containing invalid Python code"
|
||||
)
|
||||
def step_config_error_tool(context):
|
||||
_setup_context_from_text(context, context.text)
|
||||
|
||||
|
||||
@given(
|
||||
"a configuration for a ToolAgent with an invalid custom code tool config '{config_json}'"
|
||||
)
|
||||
def step_config_invalid_tool(context, config_json):
|
||||
config_dict = json.loads(config_json)
|
||||
context.config_manager = ConfigurationManager()
|
||||
context.config_manager.config = config_dict
|
||||
if not hasattr(context, "template_renderer"):
|
||||
context.template_renderer = TemplateRenderer()
|
||||
context.agent_factory = AgentFactory(
|
||||
context.config_manager.to_dict(), context.template_renderer
|
||||
)
|
||||
|
||||
|
||||
@given('I create the ToolAgent named "{agent_name}"')
|
||||
@when('I create the ToolAgent named "{agent_name}"')
|
||||
def step_create_tool_agent(context, agent_name):
|
||||
context.error = None
|
||||
try:
|
||||
context.agent = context.agent_factory.create_agent(agent_name)
|
||||
assert context.agent is not None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
raise AssertionError(f"Agent creation failed unexpectedly: {e}")
|
||||
|
||||
|
||||
@when('I try to create the ToolAgent named "{agent_name}"')
|
||||
def step_try_create_tool_agent(context, agent_name):
|
||||
context.error = None
|
||||
try:
|
||||
context.agent = context.agent_factory.create_agent(agent_name)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when('I process this message "{message}" with the agent')
|
||||
def step_process_message(context, message):
|
||||
context.response = None
|
||||
context.error = None
|
||||
|
||||
async def run():
|
||||
try:
|
||||
context.response = await context.agent.process(message)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@when('I process this message "{message}" with the agent with context')
|
||||
def step_process_message_with_context(context, message):
|
||||
context.response = None
|
||||
context.error = None
|
||||
message_context = json.loads(context.text)
|
||||
|
||||
async def run():
|
||||
try:
|
||||
context.response = await context.agent.process(
|
||||
message, context=message_context
|
||||
)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@when('I try to process the message "{message}" with the agent')
|
||||
def step_try_process_message(context, message):
|
||||
context.response = None
|
||||
context.error = None
|
||||
|
||||
async def run():
|
||||
try:
|
||||
context.response = await context.agent.process(message)
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@then('the response should be the following "{expected_response}"')
|
||||
def step_assert_response(context, expected_response):
|
||||
assert context.error is None, f"Expected no error, but got {context.error}"
|
||||
assert (
|
||||
context.response == expected_response
|
||||
), f"Expected '{expected_response}', but got '{context.response}'"
|
||||
|
||||
|
||||
@then('the response should be the following ""')
|
||||
def step_assert_response_empty(context):
|
||||
"""
|
||||
Step definition for asserting an empty string response.
|
||||
|
||||
Using an explicit pattern without a parameter avoids issues where
|
||||
behave cannot capture an empty string argument.
|
||||
"""
|
||||
expected_response = ""
|
||||
assert context.error is None, f"Expected no error, but got {context.error}"
|
||||
assert (
|
||||
context.response == expected_response
|
||||
), f"Expected '{expected_response}', but got '{context.response}'"
|
||||
|
||||
|
||||
@then('an ExecutionError should be raised with the message containing "{text}"')
|
||||
def step_assert_execution_error(context, text):
|
||||
assert (
|
||||
context.error is not None
|
||||
), "Expected an ExecutionError but no exception was raised."
|
||||
assert isinstance(
|
||||
context.error, ExecutionError
|
||||
), f"Expected ExecutionError, but got {type(context.error).__name__}"
|
||||
assert text in str(
|
||||
context.error
|
||||
), f"Expected error message to contain '{text}', but it was '{str(context.error)}'"
|
||||
|
||||
|
||||
@then('a ConfigurationError should be raised with the message containing "{text}"')
|
||||
def step_assert_configuration_error(context, text):
|
||||
assert (
|
||||
context.error is not None
|
||||
), "Expected a ConfigurationError but no exception was raised."
|
||||
assert isinstance(
|
||||
context.error, ConfigurationError
|
||||
), f"Expected ConfigurationError, but got {type(context.error).__name__}"
|
||||
assert text in str(
|
||||
context.error
|
||||
), f"Expected error message to contain '{text}', but it was '{str(context.error)}'"
|
||||
@@ -48,14 +48,14 @@ Feature: Detailed Tool Agent and Tool Functionality
|
||||
Scenario Outline: ToolAgent initialization fails with invalid tool configurations
|
||||
Given a ToolAgent configuration with an invalid tool entry: <invalid_config>
|
||||
When I try to create a ToolAgent with this configuration
|
||||
Then an AgentCreationError should be raised with a message containing "<error_message>"
|
||||
Then a ConfigurationError should be raised with a message containing "<message>"
|
||||
|
||||
Examples:
|
||||
| invalid_config | error_message |
|
||||
| invalid_config | message |
|
||||
| 'not_a_dictionary' | Each tool configuration must be a dictionary |
|
||||
| '{"name": ""}' | Tool 'name' must be a non-empty string |
|
||||
| '{"name": 123}' | Tool 'name' must be a non-empty string |
|
||||
| '{"name": "custom_tool"}' | No module specified for custom tool 'custom_tool' |
|
||||
| '{"name": "custom_tool"}' | is not a built-in tool and is missing a 'code' or 'module' definition. |
|
||||
| '{"name": "non_existent", "module": "non.existent"}' | Failed to load tool 'non_existent' from module 'non.existent' |
|
||||
| '{"name": "bad_class", "module": "tests.custom_module"}' | has no attribute 'BadClassTool' |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user