feat: add support for custom code tools and enhance error handling

This commit is contained in:
2025-07-07 00:39:54 +00:00
parent 7fe3521a2c
commit 2fa7e1c913
6 changed files with 421 additions and 16 deletions
+12 -1
View File
@@ -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
View File
@@ -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
+92
View File
@@ -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 |
+10 -2
View File
@@ -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)}'"
+3 -3
View File
@@ -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' |