From e84666870dd1c04237fa606c9599cd1c1254bf43 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 30 Jan 2026 10:08:28 -0500 Subject: [PATCH 1/6] add tool decorator Signed-off-by: Akihiko Kuroda --- docs/examples/tools/interpreter_example.py | 26 +- docs/examples/tools/tool_decorator_example.py | 135 ++++++++ mellea/backends/__init__.py | 3 + mellea/backends/tools.py | 105 +++++- test/backends/test_tool_decorator.py | 305 ++++++++++++++++++ 5 files changed, 572 insertions(+), 2 deletions(-) create mode 100644 docs/examples/tools/tool_decorator_example.py create mode 100644 test/backends/test_tool_decorator.py diff --git a/docs/examples/tools/interpreter_example.py b/docs/examples/tools/interpreter_example.py index ea77e801..565c2802 100644 --- a/docs/examples/tools/interpreter_example.py +++ b/docs/examples/tools/interpreter_example.py @@ -1,11 +1,35 @@ # pytest: ollama, llm from mellea import MelleaSession, start_session -from mellea.backends import ModelOption +from mellea.backends import ModelOption, tool from mellea.stdlib.requirements import tool_arg_validator, uses_tool from mellea.stdlib.tools import code_interpreter, local_code_interpreter +# Example: Define a custom tool using the @tool decorator +@tool +def get_weather(location: str, days: int = 1) -> dict: + """Get weather forecast for a location. + + Args: + location: City name + days: Number of days to forecast (default: 1) + """ + # Mock implementation + return {"location": location, "days": days, "forecast": "sunny", "temperature": 72} + + +@tool(name="custom_calculator") +def calculate(expression: str) -> float: + """Evaluate a mathematical expression. + + Args: + expression: Mathematical expression to evaluate + """ + # Simple mock - in production, use safe evaluation + return eval(expression) + + def example_1(m: MelleaSession): # First, let's see how the code interpreter function works without an LLM in the loop: result = code_interpreter("print(1+1)") diff --git a/docs/examples/tools/tool_decorator_example.py b/docs/examples/tools/tool_decorator_example.py new file mode 100644 index 00000000..084d2072 --- /dev/null +++ b/docs/examples/tools/tool_decorator_example.py @@ -0,0 +1,135 @@ +# pytest: ollama, llm +"""Example demonstrating the @tool decorator for cleaner tool definitions.""" + +from mellea import start_session +from mellea.backends import ModelOption, tool + + +# Define tools using the @tool decorator - much cleaner than MelleaTool.from_callable() +@tool +def get_weather(location: str, days: int = 1) -> dict: + """Get weather forecast for a location. + + Args: + location: City name + days: Number of days to forecast (default: 1) + """ + # Mock implementation + return {"location": location, "days": days, "forecast": "sunny", "temperature": 72} + + +@tool +def search_web(query: str, max_results: int = 5) -> list[str]: + """Search the web for information. + + Args: + query: Search query + max_results: Maximum number of results to return + """ + # Mock implementation + return [f"Result {i + 1} for '{query}'" for i in range(max_results)] + + +@tool(name="calculator") +def calculate(expression: str) -> str: + """Evaluate a mathematical expression. + + Args: + expression: Mathematical expression to evaluate + """ + try: + # Simple mock - in production, use safe evaluation + result = eval(expression) + return f"Result: {result}" + except Exception as e: + return f"Error: {str(e)}" + + +def example_basic_usage(): + """Example 1: Basic usage with decorated tools.""" + print("\n=== Example 1: Basic Tool Usage ===") + + # Before the decorator, you had to do: + # tools = [MelleaTool.from_callable(get_weather), MelleaTool.from_callable(search_web)] + + # Now you can just pass the decorated functions directly: + tools = [get_weather, search_web, calculate] + + # The decorated functions still work as normal functions + weather = get_weather("Boston", days=3) + print(f"Direct call: {weather}") + + # And they have tool properties + print(f"Tool name: {get_weather.name}") + print(f"Tool has JSON schema: {'function' in get_weather.as_json_tool}") + + +def example_with_llm(): + """Example 2: Using decorated tools with an LLM.""" + print("\n=== Example 2: Using Tools with LLM ===") + + m = start_session() + + # Pass decorated tools directly - no wrapping needed! + response = m.instruct( + description="What's the weather like in San Francisco?", + model_options={ModelOption.TOOLS: [get_weather, search_web]}, + ) + + print(f"Response: {response}") + + +def example_custom_name(): + """Example 3: Using custom tool names.""" + print("\n=== Example 3: Custom Tool Names ===") + + # The calculator tool was decorated with @tool(name="calculator") + # So its name is "calculator" instead of "calculate" + print(f"Function name: calculate") + print(f"Tool name: {calculate.name}") + + # Both work the same way + result = calculate("2 + 2") + print(f"Result: {result}") + + +def example_comparison(): + """Example 4: Comparison of old vs new approach.""" + print("\n=== Example 4: Old vs New Approach ===") + + # OLD APPROACH (still works, but verbose): + from mellea.backends.tools import MelleaTool + + def old_style_tool(x: int) -> int: + """Old style tool. + + Args: + x: Input value + """ + return x * 2 + + old_tool = MelleaTool.from_callable(old_style_tool) + print(f"Old approach - tool name: {old_tool.name}") + + # NEW APPROACH (cleaner): + @tool + def new_style_tool(x: int) -> int: + """New style tool. + + Args: + x: Input value + """ + return x * 2 + + print(f"New approach - tool name: {new_style_tool.name}") + + # Both can be used together in a tools list + tools = [old_tool, new_style_tool, get_weather] + print(f"Mixed tools list: {[t.name for t in tools]}") + + +if __name__ == "__main__": + example_basic_usage() + # example_with_llm() # Uncomment to test with actual LLM + example_custom_name() + example_comparison() diff --git a/mellea/backends/__init__.py b/mellea/backends/__init__.py index 564d9950..9dd45518 100644 --- a/mellea/backends/__init__.py +++ b/mellea/backends/__init__.py @@ -6,12 +6,15 @@ from .cache import SimpleLRUCache from .model_ids import ModelIdentifier from .model_options import ModelOption +from .tools import MelleaTool, tool __all__ = [ "Backend", "BaseModelSubclass", "FormatterBackend", + "MelleaTool", "ModelIdentifier", "ModelOption", "SimpleLRUCache", + "tool", ] diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 09249c66..1b2f845e 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -91,10 +91,107 @@ def from_callable(cls, func: Callable, name: str | None = None): return MelleaTool(tool_name, tool_call, as_json) +def tool(func: Callable | None = None, *, name: str | None = None) -> Any: + """Decorator to mark a function as a Mellea tool. + + This decorator wraps a function to make it usable as a tool without + requiring explicit MelleaTool.from_callable() calls. The decorated + function can be used both as a normal callable and as a MelleaTool. + + Args: + func: The function to decorate (when used without arguments) + name: Optional custom name for the tool (defaults to function name) + + Returns: + A callable that behaves like both the original function and a MelleaTool + + Examples: + Basic usage: + >>> @tool + ... def get_weather(location: str, days: int = 1) -> dict: + ... '''Get weather forecast. + ... + ... Args: + ... location: City name + ... days: Number of days to forecast + ... ''' + ... return {"location": location, "forecast": "sunny"} + >>> # Can be used directly in tools list + >>> tools = [get_weather] + >>> # Can still be called normally + >>> result = get_weather("Boston") + + With custom name: + >>> @tool(name="weather_api") + ... def get_weather(location: str) -> dict: + ... return {"location": location} + """ + + def decorator(f: Callable) -> Any: + # Create the MelleaTool instance + mellea_tool = MelleaTool.from_callable(f, name=name) + + # Create a wrapper that preserves the original function behavior + # but also acts as a MelleaTool + class ToolWrapper: + """Wrapper that makes a function behave like both a callable and a MelleaTool.""" + + def __init__(self, func: Callable, tool: MelleaTool): + self._func = func + self._tool = tool + self._mellea_tool = tool # Store for direct access + # Copy function metadata + self.__name__ = func.__name__ + self.__doc__ = func.__doc__ + self.__module__ = func.__module__ + self.__qualname__ = func.__qualname__ + self.__annotations__ = func.__annotations__ + self.__dict__.update(func.__dict__) + + def __call__(self, *args, **kwargs) -> Any: + """Allow the wrapper to be called like the original function.""" + return self._func(*args, **kwargs) + + def __repr__(self) -> str: + return f"" + + # Expose MelleaTool interface + @property + def name(self) -> str: + """Get the tool name.""" + return self._tool.name + + @property + def as_json_tool(self) -> dict[str, Any]: + """Get the tool as JSON schema.""" + return self._tool.as_json_tool + + def run(self, *args, **kwargs) -> Any: + """Run the tool (same as calling it).""" + return self._tool.run(*args, **kwargs) + + # Make it work with isinstance checks + def __instancecheck__(self, instance) -> bool: + return isinstance(instance, MelleaTool | AbstractMelleaTool) + + return ToolWrapper(f, mellea_tool) + + # Handle both @tool and @tool() syntax + if func is None: + # Called with arguments: @tool(name="custom") + return decorator + else: + # Called without arguments: @tool + return decorator(func) + + def add_tools_from_model_options( tools_dict: dict[str, AbstractMelleaTool], model_options: dict[str, Any] ): - """If model_options has tools, add those tools to the tools_dict.""" + """If model_options has tools, add those tools to the tools_dict. + + Accepts MelleaTool instances or @tool decorated functions. + """ model_opts_tools = model_options.get(ModelOption.TOOLS, None) if model_opts_tools is None: return @@ -110,6 +207,9 @@ def add_tools_from_model_options( assert isinstance(tool_name, str), ( f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Tool]; found {type(tool_name)} as the key instead" ) + # Extract MelleaTool from decorated functions + if hasattr(tool_instance, "_mellea_tool"): + tool_instance = tool_instance._mellea_tool assert isinstance(tool_instance, MelleaTool), ( f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Tool]; found {type(tool_instance)} as the value instead" ) @@ -117,6 +217,9 @@ def add_tools_from_model_options( else: # Handle any other iterable / list here. for tool_instance in model_opts_tools: + # Extract MelleaTool from decorated functions + if hasattr(tool_instance, "_mellea_tool"): + tool_instance = tool_instance._mellea_tool assert isinstance(tool_instance, MelleaTool), ( f"If ModelOption.TOOLS is a list, it must be a list of Tool; found {type(tool_instance)}" ) diff --git a/test/backends/test_tool_decorator.py b/test/backends/test_tool_decorator.py new file mode 100644 index 00000000..83f663ba --- /dev/null +++ b/test/backends/test_tool_decorator.py @@ -0,0 +1,305 @@ +"""Tests for the @tool decorator.""" + +import pytest + +from mellea.backends import MelleaTool, tool +from mellea.core import ModelToolCall + + +# ============================================================================ +# Test Fixtures - Tool Functions +# ============================================================================ + + +@tool +def simple_tool(message: str) -> str: + """A simple tool that takes a string. + + Args: + message: The message to process + """ + return f"Processed: {message}" + + +@tool(name="custom_name") +def tool_with_custom_name(value: int) -> int: + """Tool with custom name. + + Args: + value: A value to process + """ + return value * 2 + + +@tool +def multi_param_tool(name: str, age: int, active: bool = True) -> dict: + """Tool with multiple parameters. + + Args: + name: Person's name + age: Person's age + active: Whether active + """ + return {"name": name, "age": age, "active": active} + + +def undecorated_function(x: int) -> int: + """A regular function without the decorator. + + Args: + x: Input value + """ + return x + 1 + + +# ============================================================================ +# Test Cases: Basic Decorator Functionality +# ============================================================================ + + +class TestToolDecoratorBasics: + """Test basic decorator functionality.""" + + def test_decorated_function_is_callable(self): + """Test that decorated function can still be called normally.""" + result = simple_tool("hello") + assert result == "Processed: hello" + + def test_decorated_function_has_name_attribute(self): + """Test that decorated function has name attribute.""" + assert hasattr(simple_tool, "name") + assert simple_tool.name == "simple_tool" + + def test_decorated_function_has_as_json_tool(self): + """Test that decorated function has as_json_tool property.""" + assert hasattr(simple_tool, "as_json_tool") + json_tool = simple_tool.as_json_tool + assert isinstance(json_tool, dict) + assert "function" in json_tool + + def test_decorated_function_has_run_method(self): + """Test that decorated function has run method.""" + assert hasattr(simple_tool, "run") + result = simple_tool.run("test") + assert result == "Processed: test" + + def test_decorated_function_preserves_metadata(self): + """Test that decorator preserves function metadata.""" + assert simple_tool.__name__ == "simple_tool" + assert "simple tool" in simple_tool.__doc__.lower() + + def test_custom_name_decorator(self): + """Test decorator with custom name parameter.""" + assert tool_with_custom_name.name == "custom_name" + # Function should still work + result = tool_with_custom_name(5) + assert result == 10 + + +# ============================================================================ +# Test Cases: Integration with MelleaTool +# ============================================================================ + + +class TestToolDecoratorIntegration: + """Test integration with existing MelleaTool infrastructure.""" + + def test_decorated_tool_in_list(self): + """Test that decorated tools can be used in a list.""" + tools = [simple_tool, multi_param_tool] + assert len(tools) == 2 + # Should be able to access tool properties + assert tools[0].name == "simple_tool" + assert tools[1].name == "multi_param_tool" + + def test_decorated_tool_with_model_tool_call(self): + """Test that decorated tools work with ModelToolCall.""" + args = {"message": "test message"} + tool_call = ModelToolCall("simple_tool", simple_tool._mellea_tool, args) + result = tool_call.call_func() + assert result == "Processed: test message" + + def test_decorated_tool_json_schema(self): + """Test that decorated tool generates correct JSON schema.""" + json_tool = simple_tool.as_json_tool + assert json_tool["type"] == "function" + assert json_tool["function"]["name"] == "simple_tool" + assert "parameters" in json_tool["function"] + properties = json_tool["function"]["parameters"]["properties"] + assert "message" in properties + assert properties["message"]["type"] == "string" + + def test_multi_param_tool_schema(self): + """Test schema generation for multi-parameter tool.""" + json_tool = multi_param_tool.as_json_tool + properties = json_tool["function"]["parameters"]["properties"] + assert "name" in properties + assert "age" in properties + assert "active" in properties + # Check required fields + required = json_tool["function"]["parameters"]["required"] + assert "name" in required + assert "age" in required + # active has default, so might not be required + + +# ============================================================================ +# Test Cases: Comparison with from_callable +# ============================================================================ + + +class TestToolDecoratorVsFromCallable: + """Test that decorator produces equivalent results to from_callable.""" + + def test_decorator_equivalent_to_from_callable(self): + """Test that @tool produces same result as MelleaTool.from_callable.""" + # Create tool using from_callable + manual_tool = MelleaTool.from_callable(undecorated_function) + + # Create tool using decorator + @tool + def decorated_version(x: int) -> int: + """A regular function without the decorator. + + Args: + x: Input value + """ + return x + 1 + + # Compare JSON schemas + manual_json = manual_tool.as_json_tool + decorated_json = decorated_version.as_json_tool + + # Names should match + assert manual_json["function"]["name"] == "undecorated_function" + assert decorated_json["function"]["name"] == "decorated_version" + + # Parameters should have same structure + assert ( + manual_json["function"]["parameters"]["type"] + == decorated_json["function"]["parameters"]["type"] + ) + + def test_both_approaches_work_in_tools_list(self): + """Test that both decorated and from_callable tools work together.""" + manual_tool = MelleaTool.from_callable(undecorated_function) + tools = [simple_tool, manual_tool] + + # Both should have name attribute + assert hasattr(tools[0], "name") + assert hasattr(tools[1], "name") + + # Both should have as_json_tool + assert hasattr(tools[0], "as_json_tool") + assert hasattr(tools[1], "as_json_tool") + + +# ============================================================================ +# Test Cases: Edge Cases +# ============================================================================ + + +class TestToolDecoratorEdgeCases: + """Test edge cases and error conditions.""" + + def test_decorator_with_no_params_function(self): + """Test decorator on function with no parameters.""" + + @tool + def no_params() -> str: + """Function with no parameters.""" + return "no params" + + result = no_params() + assert result == "no params" + assert no_params.name == "no_params" + + def test_decorator_preserves_function_behavior(self): + """Test that decorator doesn't change function behavior.""" + + @tool + def add(a: int, b: int) -> int: + """Add two numbers. + + Args: + a: First number + b: Second number + """ + return a + b + + # Should work exactly like the original function + assert add(2, 3) == 5 + assert add(10, 20) == 30 + assert add.run(5, 7) == 12 + + def test_decorator_with_complex_types(self): + """Test decorator with complex parameter types.""" + + @tool + def complex_tool(items: list[str], config: dict) -> int: + """Tool with complex types. + + Args: + items: List of items + config: Configuration dict + """ + return len(items) + len(config) + + result = complex_tool(["a", "b"], {"x": 1, "y": 2}) + assert result == 4 + + def test_multiple_decorators_on_same_function(self): + """Test that decorator can be applied multiple times (creates new instances).""" + + def base_func(x: int) -> int: + """Base function. + + Args: + x: Input + """ + return x + + tool1 = tool(base_func) + tool2 = tool(name="custom")(base_func) + + assert tool1.name == "base_func" + assert tool2.name == "custom" + + +# ============================================================================ +# Test Cases: Usage Patterns +# ============================================================================ + + +class TestToolDecoratorUsagePatterns: + """Test common usage patterns.""" + + def test_tools_in_dict(self): + """Test using decorated tools in a dictionary.""" + tools_dict = {"simple": simple_tool, "multi": multi_param_tool} + + assert tools_dict["simple"].name == "simple_tool" + assert tools_dict["multi"].name == "multi_param_tool" + + def test_tools_passed_to_function(self): + """Test passing decorated tools to a function.""" + + def process_tools(tool_list): + """Process a list of tools.""" + return [t.name for t in tool_list] + + tools = [simple_tool, multi_param_tool] + names = process_tools(tools) + assert "simple_tool" in names + assert "multi_param_tool" in names + + def test_accessing_underlying_mellea_tool(self): + """Test accessing the underlying MelleaTool instance.""" + assert hasattr(simple_tool, "_mellea_tool") + underlying = simple_tool._mellea_tool + assert isinstance(underlying, MelleaTool) + assert underlying.name == "simple_tool" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 9c0fda46c246d5f9d9b8b4874429830ca3436064 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 2 Feb 2026 10:55:05 -0500 Subject: [PATCH 2/6] review comments Signed-off-by: Akihiko Kuroda --- docs/examples/tools/interpreter_example.py | 24 ----- docs/examples/tools/tool_decorator_example.py | 2 +- mellea/backends/tools.py | 96 +++++++++---------- test/backends/test_tool_decorator.py | 14 +-- 4 files changed, 56 insertions(+), 80 deletions(-) diff --git a/docs/examples/tools/interpreter_example.py b/docs/examples/tools/interpreter_example.py index 565c2802..3b8c5546 100644 --- a/docs/examples/tools/interpreter_example.py +++ b/docs/examples/tools/interpreter_example.py @@ -6,30 +6,6 @@ from mellea.stdlib.tools import code_interpreter, local_code_interpreter -# Example: Define a custom tool using the @tool decorator -@tool -def get_weather(location: str, days: int = 1) -> dict: - """Get weather forecast for a location. - - Args: - location: City name - days: Number of days to forecast (default: 1) - """ - # Mock implementation - return {"location": location, "days": days, "forecast": "sunny", "temperature": 72} - - -@tool(name="custom_calculator") -def calculate(expression: str) -> float: - """Evaluate a mathematical expression. - - Args: - expression: Mathematical expression to evaluate - """ - # Simple mock - in production, use safe evaluation - return eval(expression) - - def example_1(m: MelleaSession): # First, let's see how the code interpreter function works without an LLM in the loop: result = code_interpreter("print(1+1)") diff --git a/docs/examples/tools/tool_decorator_example.py b/docs/examples/tools/tool_decorator_example.py index 084d2072..46d7e8a0 100644 --- a/docs/examples/tools/tool_decorator_example.py +++ b/docs/examples/tools/tool_decorator_example.py @@ -49,7 +49,7 @@ def example_basic_usage(): """Example 1: Basic usage with decorated tools.""" print("\n=== Example 1: Basic Tool Usage ===") - # Before the decorator, you had to do: + # Without the decorator, you can add tools using: # tools = [MelleaTool.from_callable(get_weather), MelleaTool.from_callable(search_web)] # Now you can just pass the decorated functions directly: diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 1b2f845e..fb527117 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -5,7 +5,7 @@ import re from collections import defaultdict from collections.abc import Callable, Generator, Iterable, Mapping, Sequence -from typing import Any, Literal +from typing import Any, Literal, overload from pydantic import BaseModel, ConfigDict, Field @@ -91,19 +91,31 @@ def from_callable(cls, func: Callable, name: str | None = None): return MelleaTool(tool_name, tool_call, as_json) -def tool(func: Callable | None = None, *, name: str | None = None) -> Any: +@overload +def tool(func: Callable) -> MelleaTool: ... + + +@overload +def tool(*, name: str | None = None) -> Callable[[Callable], MelleaTool]: ... + + +def tool( + func: Callable | None = None, *, name: str | None = None +) -> MelleaTool | Callable[[Callable], MelleaTool]: """Decorator to mark a function as a Mellea tool. This decorator wraps a function to make it usable as a tool without requiring explicit MelleaTool.from_callable() calls. The decorated - function can be used both as a normal callable and as a MelleaTool. + function IS a MelleaTool instance (via inheritance) and can be used + both as a normal callable and passed directly wherever MelleaTool is expected. Args: func: The function to decorate (when used without arguments) name: Optional custom name for the tool (defaults to function name) Returns: - A callable that behaves like both the original function and a MelleaTool + A MelleaTool instance that can be called directly like the original function. + The returned object passes isinstance(result, MelleaTool) checks. Examples: Basic usage: @@ -116,10 +128,18 @@ def tool(func: Callable | None = None, *, name: str | None = None) -> Any: ... days: Number of days to forecast ... ''' ... return {"location": location, "forecast": "sunny"} - >>> # Can be used directly in tools list + >>> + >>> # The decorated function IS a MelleaTool + >>> isinstance(get_weather, MelleaTool) # True + >>> + >>> # Can be used directly in tools list (no extraction needed) >>> tools = [get_weather] + >>> >>> # Can still be called normally >>> result = get_weather("Boston") + >>> + >>> # Or use the .run() interface + >>> result = get_weather.run(location="Boston") With custom name: >>> @tool(name="weather_api") @@ -127,54 +147,34 @@ def tool(func: Callable | None = None, *, name: str | None = None) -> Any: ... return {"location": location} """ - def decorator(f: Callable) -> Any: - # Create the MelleaTool instance - mellea_tool = MelleaTool.from_callable(f, name=name) + def decorator(f: Callable) -> MelleaTool: + # Create the base MelleaTool instance + base_tool = MelleaTool.from_callable(f, name=name) - # Create a wrapper that preserves the original function behavior - # but also acts as a MelleaTool - class ToolWrapper: - """Wrapper that makes a function behave like both a callable and a MelleaTool.""" + # Create an enhanced MelleaTool subclass that supports direct calling + class CallableMelleaTool(MelleaTool): + """MelleaTool subclass that supports direct calling like the original function.""" - def __init__(self, func: Callable, tool: MelleaTool): - self._func = func - self._tool = tool - self._mellea_tool = tool # Store for direct access - # Copy function metadata + def __init__(self, base: MelleaTool, func: Callable): + # Initialize with base tool's attributes + super().__init__(base.name, base._call_tool, base._as_json_tool) + # Store original function for direct calling + self._original_func = func + # Copy function metadata for better introspection self.__name__ = func.__name__ self.__doc__ = func.__doc__ self.__module__ = func.__module__ self.__qualname__ = func.__qualname__ self.__annotations__ = func.__annotations__ - self.__dict__.update(func.__dict__) def __call__(self, *args, **kwargs) -> Any: - """Allow the wrapper to be called like the original function.""" - return self._func(*args, **kwargs) + """Allow the tool to be called directly like the original function.""" + return self._original_func(*args, **kwargs) def __repr__(self) -> str: - return f"" - - # Expose MelleaTool interface - @property - def name(self) -> str: - """Get the tool name.""" - return self._tool.name - - @property - def as_json_tool(self) -> dict[str, Any]: - """Get the tool as JSON schema.""" - return self._tool.as_json_tool + return f"" - def run(self, *args, **kwargs) -> Any: - """Run the tool (same as calling it).""" - return self._tool.run(*args, **kwargs) - - # Make it work with isinstance checks - def __instancecheck__(self, instance) -> bool: - return isinstance(instance, MelleaTool | AbstractMelleaTool) - - return ToolWrapper(f, mellea_tool) + return CallableMelleaTool(base_tool, f) # Handle both @tool and @tool() syntax if func is None: @@ -207,22 +207,20 @@ def add_tools_from_model_options( assert isinstance(tool_name, str), ( f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Tool]; found {type(tool_name)} as the key instead" ) - # Extract MelleaTool from decorated functions - if hasattr(tool_instance, "_mellea_tool"): - tool_instance = tool_instance._mellea_tool - assert isinstance(tool_instance, MelleaTool), ( + assert isinstance(tool_instance, AbstractMelleaTool), ( f"If ModelOption.TOOLS is a dict, it must be a dict of [str, Tool]; found {type(tool_instance)} as the value instead" ) tools_dict[tool_name] = tool_instance else: # Handle any other iterable / list here. for tool_instance in model_opts_tools: - # Extract MelleaTool from decorated functions - if hasattr(tool_instance, "_mellea_tool"): - tool_instance = tool_instance._mellea_tool - assert isinstance(tool_instance, MelleaTool), ( + assert isinstance(tool_instance, AbstractMelleaTool), ( f"If ModelOption.TOOLS is a list, it must be a list of Tool; found {type(tool_instance)}" ) + # MelleaTool (and subclasses like CallableMelleaTool) have a name attribute + assert isinstance(tool_instance, MelleaTool), ( + f"Tool must be a MelleaTool instance with a name attribute; found {type(tool_instance)}" + ) tools_dict[tool_instance.name] = tool_instance diff --git a/test/backends/test_tool_decorator.py b/test/backends/test_tool_decorator.py index 83f663ba..58d31072 100644 --- a/test/backends/test_tool_decorator.py +++ b/test/backends/test_tool_decorator.py @@ -115,7 +115,8 @@ def test_decorated_tool_in_list(self): def test_decorated_tool_with_model_tool_call(self): """Test that decorated tools work with ModelToolCall.""" args = {"message": "test message"} - tool_call = ModelToolCall("simple_tool", simple_tool._mellea_tool, args) + # Decorated function IS a MelleaTool, can be passed directly + tool_call = ModelToolCall("simple_tool", simple_tool, args) result = tool_call.call_func() assert result == "Processed: test message" @@ -294,11 +295,12 @@ def process_tools(tool_list): assert "multi_param_tool" in names def test_accessing_underlying_mellea_tool(self): - """Test accessing the underlying MelleaTool instance.""" - assert hasattr(simple_tool, "_mellea_tool") - underlying = simple_tool._mellea_tool - assert isinstance(underlying, MelleaTool) - assert underlying.name == "simple_tool" + """Test that decorated function IS a MelleaTool instance.""" + assert isinstance(simple_tool, MelleaTool) + assert simple_tool.name == "simple_tool" + # Verify it has all MelleaTool properties + assert hasattr(simple_tool, "as_json_tool") + assert hasattr(simple_tool, "run") if __name__ == "__main__": From 4d384e6f1e1c660ccd72d3c3c9e9870d083927d6 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Tue, 3 Feb 2026 09:24:47 -0500 Subject: [PATCH 3/6] fix merge error Signed-off-by: Akihiko Kuroda --- docs/examples/tools/tool_decorator_example.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/examples/tools/tool_decorator_example.py b/docs/examples/tools/tool_decorator_example.py index 46d7e8a0..c04d5682 100644 --- a/docs/examples/tools/tool_decorator_example.py +++ b/docs/examples/tools/tool_decorator_example.py @@ -1,6 +1,8 @@ # pytest: ollama, llm """Example demonstrating the @tool decorator for cleaner tool definitions.""" +import ast + from mellea import start_session from mellea.backends import ModelOption, tool @@ -38,11 +40,11 @@ def calculate(expression: str) -> str: expression: Mathematical expression to evaluate """ try: - # Simple mock - in production, use safe evaluation - result = eval(expression) + # Use ast.literal_eval for safe evaluation of simple expressions + result = ast.literal_eval(expression) return f"Result: {result}" except Exception as e: - return f"Error: {str(e)}" + return f"Error: {e!s}" def example_basic_usage(): @@ -52,8 +54,8 @@ def example_basic_usage(): # Without the decorator, you can add tools using: # tools = [MelleaTool.from_callable(get_weather), MelleaTool.from_callable(search_web)] - # Now you can just pass the decorated functions directly: - tools = [get_weather, search_web, calculate] + # Now you can just pass the decorated functions directly to model_options + # Example: model_options={ModelOption.TOOLS: [get_weather, search_web, calculate]} # The decorated functions still work as normal functions weather = get_weather("Boston", days=3) @@ -85,7 +87,7 @@ def example_custom_name(): # The calculator tool was decorated with @tool(name="calculator") # So its name is "calculator" instead of "calculate" - print(f"Function name: calculate") + print("Function name: calculate") print(f"Tool name: {calculate.name}") # Both work the same way From e564cdb4d2cbf66d3b0c5d871ba3919151fb1cad Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Tue, 3 Feb 2026 09:54:31 -0500 Subject: [PATCH 4/6] fix lint error Signed-off-by: Akihiko Kuroda --- test/backends/test_tool_decorator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/backends/test_tool_decorator.py b/test/backends/test_tool_decorator.py index 58d31072..12fedd02 100644 --- a/test/backends/test_tool_decorator.py +++ b/test/backends/test_tool_decorator.py @@ -5,7 +5,6 @@ from mellea.backends import MelleaTool, tool from mellea.core import ModelToolCall - # ============================================================================ # Test Fixtures - Tool Functions # ============================================================================ From 86440b405b22dfa2fd98ffb4b6b10a5a3cb8f9c4 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Tue, 3 Feb 2026 19:42:02 -0500 Subject: [PATCH 5/6] review comments Signed-off-by: Akihiko Kuroda --- docs/examples/tools/tool_decorator_example.py | 10 ++--- docs/tutorial.md | 8 +++- mellea/backends/tools.py | 41 ++++--------------- test/backends/test_tool_decorator.py | 25 ++++++----- 4 files changed, 33 insertions(+), 51 deletions(-) diff --git a/docs/examples/tools/tool_decorator_example.py b/docs/examples/tools/tool_decorator_example.py index c04d5682..e35e1405 100644 --- a/docs/examples/tools/tool_decorator_example.py +++ b/docs/examples/tools/tool_decorator_example.py @@ -57,9 +57,9 @@ def example_basic_usage(): # Now you can just pass the decorated functions directly to model_options # Example: model_options={ModelOption.TOOLS: [get_weather, search_web, calculate]} - # The decorated functions still work as normal functions - weather = get_weather("Boston", days=3) - print(f"Direct call: {weather}") + # The decorated tools must be called using .run() + weather = get_weather.run("Boston", days=3) + print(f"Tool call via .run(): {weather}") # And they have tool properties print(f"Tool name: {get_weather.name}") @@ -90,8 +90,8 @@ def example_custom_name(): print("Function name: calculate") print(f"Tool name: {calculate.name}") - # Both work the same way - result = calculate("2 + 2") + # Must use .run() to invoke + result = calculate.run("2 + 2") print(f"Result: {result}") diff --git a/docs/tutorial.md b/docs/tutorial.md index 2fa97af3..d494b6a8 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1347,9 +1347,15 @@ For examples on adding tools to the template representation of a component, see Here's an example of adding a tool through model options. This can be useful when you want to add a tool like web search that should almost always be available: ```python import mellea -from mellea.backends import ModelOption +from mellea.backends import ModelOption, tool +@tool def web_search(query: str) -> str: + """Search the web for information. + + Args: + query: The search query + """ ... m = mellea.start_session() diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index fb527117..85e8227c 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -106,15 +106,14 @@ def tool( This decorator wraps a function to make it usable as a tool without requiring explicit MelleaTool.from_callable() calls. The decorated - function IS a MelleaTool instance (via inheritance) and can be used - both as a normal callable and passed directly wherever MelleaTool is expected. + function returns a MelleaTool instance that must be called via .run(). Args: func: The function to decorate (when used without arguments) name: Optional custom name for the tool (defaults to function name) Returns: - A MelleaTool instance that can be called directly like the original function. + A MelleaTool instance. Use .run() to invoke the tool. The returned object passes isinstance(result, MelleaTool) checks. Examples: @@ -135,46 +134,20 @@ def tool( >>> # Can be used directly in tools list (no extraction needed) >>> tools = [get_weather] >>> - >>> # Can still be called normally - >>> result = get_weather("Boston") - >>> - >>> # Or use the .run() interface + >>> # Must use .run() to invoke the tool >>> result = get_weather.run(location="Boston") With custom name: >>> @tool(name="weather_api") ... def get_weather(location: str) -> dict: ... return {"location": location} + >>> + >>> result = get_weather.run(location="New York") """ def decorator(f: Callable) -> MelleaTool: - # Create the base MelleaTool instance - base_tool = MelleaTool.from_callable(f, name=name) - - # Create an enhanced MelleaTool subclass that supports direct calling - class CallableMelleaTool(MelleaTool): - """MelleaTool subclass that supports direct calling like the original function.""" - - def __init__(self, base: MelleaTool, func: Callable): - # Initialize with base tool's attributes - super().__init__(base.name, base._call_tool, base._as_json_tool) - # Store original function for direct calling - self._original_func = func - # Copy function metadata for better introspection - self.__name__ = func.__name__ - self.__doc__ = func.__doc__ - self.__module__ = func.__module__ - self.__qualname__ = func.__qualname__ - self.__annotations__ = func.__annotations__ - - def __call__(self, *args, **kwargs) -> Any: - """Allow the tool to be called directly like the original function.""" - return self._original_func(*args, **kwargs) - - def __repr__(self) -> str: - return f"" - - return CallableMelleaTool(base_tool, f) + # Simply return the base MelleaTool instance + return MelleaTool.from_callable(f, name=name) # Handle both @tool and @tool() syntax if func is None: diff --git a/test/backends/test_tool_decorator.py b/test/backends/test_tool_decorator.py index 12fedd02..139d0a95 100644 --- a/test/backends/test_tool_decorator.py +++ b/test/backends/test_tool_decorator.py @@ -60,8 +60,8 @@ class TestToolDecoratorBasics: """Test basic decorator functionality.""" def test_decorated_function_is_callable(self): - """Test that decorated function can still be called normally.""" - result = simple_tool("hello") + """Test that decorated function can be called via .run().""" + result = simple_tool.run("hello") assert result == "Processed: hello" def test_decorated_function_has_name_attribute(self): @@ -84,14 +84,17 @@ def test_decorated_function_has_run_method(self): def test_decorated_function_preserves_metadata(self): """Test that decorator preserves function metadata.""" - assert simple_tool.__name__ == "simple_tool" - assert "simple tool" in simple_tool.__doc__.lower() + # MelleaTool doesn't have __name__ or __doc__ attributes + # but has name attribute and the original function's docstring in as_json_tool + assert simple_tool.name == "simple_tool" + json_tool = simple_tool.as_json_tool + assert "simple tool" in json_tool["function"]["description"].lower() def test_custom_name_decorator(self): """Test decorator with custom name parameter.""" assert tool_with_custom_name.name == "custom_name" - # Function should still work - result = tool_with_custom_name(5) + # Function should still work via .run() + result = tool_with_custom_name.run(5) assert result == 10 @@ -210,7 +213,7 @@ def no_params() -> str: """Function with no parameters.""" return "no params" - result = no_params() + result = no_params.run() assert result == "no params" assert no_params.name == "no_params" @@ -227,9 +230,9 @@ def add(a: int, b: int) -> int: """ return a + b - # Should work exactly like the original function - assert add(2, 3) == 5 - assert add(10, 20) == 30 + # Should work via .run() method + assert add.run(2, 3) == 5 + assert add.run(10, 20) == 30 assert add.run(5, 7) == 12 def test_decorator_with_complex_types(self): @@ -245,7 +248,7 @@ def complex_tool(items: list[str], config: dict) -> int: """ return len(items) + len(config) - result = complex_tool(["a", "b"], {"x": 1, "y": 2}) + result = complex_tool.run(["a", "b"], {"x": 1, "y": 2}) assert result == 4 def test_multiple_decorators_on_same_function(self): From 4095165257c2a1010596ae709979faf579405696 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Wed, 4 Feb 2026 12:20:16 -0500 Subject: [PATCH 6/6] review comment Signed-off-by: Akihiko Kuroda --- mellea/backends/tools.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 85e8227c..2fd047f4 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -100,7 +100,7 @@ def tool(*, name: str | None = None) -> Callable[[Callable], MelleaTool]: ... def tool( - func: Callable | None = None, *, name: str | None = None + func: Callable | None = None, name: str | None = None ) -> MelleaTool | Callable[[Callable], MelleaTool]: """Decorator to mark a function as a Mellea tool. @@ -137,12 +137,16 @@ def tool( >>> # Must use .run() to invoke the tool >>> result = get_weather.run(location="Boston") - With custom name: + With custom name (as decorator): >>> @tool(name="weather_api") ... def get_weather(location: str) -> dict: ... return {"location": location} >>> >>> result = get_weather.run(location="New York") + + With custom name (as function): + >>> def new_tool(): ... + >>> differently_named_tool = tool(new_tool, name="different_name") """ def decorator(f: Callable) -> MelleaTool: