From 96f492b793a2827e6a031b2ca98bd9c177358d62 Mon Sep 17 00:00:00 2001 From: TINGyu123644 <2663180454@qq.com> Date: Fri, 9 Oct 2026 20:34:11 +0800 Subject: [PATCH 1/4] fix: stop forwarding Hub download options to the tool constructor `Tool.from_hub()` documents that extra kwargs are split between Hub download options and tool-constructor options. The download options (`cache_dir`, `force_download`, `proxies`, `revision`, `subfolder`, `local_files_only`) were only read with `kwargs.get(...)`, so they stayed in `kwargs` and were forwarded to `Tool.from_code(tool_code, **kwargs)`, which eventually calls `tool_class(**kwargs)`. A tool with a no-argument constructor therefore failed with `TypeError: __init__() got an unexpected keyword argument 'cache_dir'`. Pop the download options instead of reading them, so only tool-specific constructor arguments reach `from_code`. Tool-specific arguments are still forwarded unchanged. Fixes #2928 --- src/smolagents/tools.py | 2842 +++++++++++++++++++-------------------- tests/test_tools.py | 2179 +++++++++++++++--------------- 2 files changed, 2536 insertions(+), 2485 deletions(-) diff --git a/src/smolagents/tools.py b/src/smolagents/tools.py index 931acfeccb..911fe7cdf1 100644 --- a/src/smolagents/tools.py +++ b/src/smolagents/tools.py @@ -1,1422 +1,1422 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from __future__ import annotations - -import ast -import inspect -import json -import logging -import os -import sys -import tempfile -import textwrap -import types -import warnings -from abc import ABC, abstractmethod -from collections.abc import Callable -from contextlib import contextmanager -from functools import wraps -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from huggingface_hub import ( - CommitOperationAdd, - create_commit, - create_repo, - get_collection, - hf_hub_download, - metadata_update, -) - -from ._function_type_hints_utils import ( - TypeHintParsingException, - _convert_type_hints_to_json_schema, - _get_json_schema_type, - get_imports, - get_json_schema, -) -from .agent_types import AgentAudio, AgentImage, handle_agent_input_types, handle_agent_output_types -from .tool_validation import MethodChecker, validate_tool_attributes -from .utils import ( - BASE_BUILTIN_MODULES, - _is_package_available, - get_source, - instance_to_source, - is_valid_name, -) - - -if TYPE_CHECKING: - import mcp - - -logger = logging.getLogger(__name__) - - -def validate_after_init(cls): - original_init = cls.__init__ - - @wraps(original_init) - def new_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - self.validate_arguments() - - cls.__init__ = new_init - return cls - - -AUTHORIZED_TYPES = [ - "string", - "boolean", - "integer", - "number", - "image", - "audio", - "array", - "object", - "any", - "null", -] - -CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} - - -class BaseTool(ABC): - name: str - - @abstractmethod - def __call__(self, *args, **kwargs) -> Any: - pass - - -class Tool(BaseTool): - """ - A base class for the functions used by the agent. Subclass this and implement the `forward` method as well as the - following class attributes: - - - **description** (`str`) -- A short description of what your tool does, the inputs it expects and the output(s) it - will return. For instance 'This is a tool that downloads a file from a `url`. It takes the `url` as input, and - returns the text contained in the file'. - - **name** (`str`) -- A performative name that will be used for your tool in the prompt to the agent. For instance - `"text-classifier"` or `"image_generator"`. - - **inputs** (`Dict[str, Dict[str, Union[str, type, bool]]]`) -- The dict of modalities expected for the inputs. - It has one `type`key and a `description`key. - This is used by `launch_gradio_demo` or to make a nice space from your tool, and also can be used in the generated - description for your tool. - - **output_type** (`type`) -- The type of the tool output. This is used by `launch_gradio_demo` - or to make a nice space from your tool, and also can be used in the generated description for your tool. - - **output_schema** (`Dict[str, Any]`, *optional*) -- The JSON schema defining the expected structure of the tool output. - This can be included in system prompts to help agents understand the expected output format. Note: This is currently - used for informational purposes only and does not perform actual output validation. - - You can also override the method [`~Tool.setup`] if your tool has an expensive operation to perform before being - usable (such as loading a model). [`~Tool.setup`] will be called the first time you use your tool, but not at - instantiation. - """ - - name: str - description: str - inputs: dict[str, dict[str, str | type | bool]] - output_type: str - output_schema: dict[str, Any] | None = None - - def __init__(self, *args, **kwargs): - self.is_initialized = False - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - validate_after_init(cls) - - def validate_arguments(self): - required_attributes = { - "description": str, - "name": str, - "inputs": dict, - "output_type": str, - } - # Validate class attributes - for attr, expected_type in required_attributes.items(): - attr_value = getattr(self, attr, None) - if attr_value is None: - raise TypeError(f"You must set an attribute {attr}.") - if not isinstance(attr_value, expected_type): - raise TypeError( - f"Attribute {attr} should have type {expected_type.__name__}, got {type(attr_value)} instead." - ) - - # Validate optional output_schema attribute - output_schema = getattr(self, "output_schema", None) - if output_schema is not None and not isinstance(output_schema, dict): - raise TypeError(f"Attribute output_schema should have type dict, got {type(output_schema)} instead.") - - # - Validate name - if not is_valid_name(self.name): - raise Exception( - f"Invalid Tool name '{self.name}': must be a valid Python identifier and not a reserved keyword" - ) - # Validate inputs - for input_name, input_content in self.inputs.items(): - assert isinstance(input_content, dict), f"Input '{input_name}' should be a dictionary." - assert "type" in input_content and "description" in input_content, ( - f"Input '{input_name}' should have keys 'type' and 'description', has only {list(input_content.keys())}." - ) - # Get input_types as a list, whether from a string or list - if isinstance(input_content["type"], str): - input_types = [input_content["type"]] - elif isinstance(input_content["type"], list): - input_types = input_content["type"] - # Check if all elements are strings - if not all(isinstance(t, str) for t in input_types): - raise TypeError( - f"Input '{input_name}': when type is a list, all elements must be strings, got {input_content['type']}" - ) - else: - raise TypeError( - f"Input '{input_name}': type must be a string or list of strings, got {type(input_content['type']).__name__}" - ) - # Check all types are authorized - invalid_types = [t for t in input_types if t not in AUTHORIZED_TYPES] - if invalid_types: - raise ValueError(f"Input '{input_name}': types {invalid_types} must be one of {AUTHORIZED_TYPES}") - # Validate output type - assert getattr(self, "output_type", None) in AUTHORIZED_TYPES - - # Validate forward function signature, except for Tools that use a "generic" signature (PipelineTool, SpaceToolWrapper, LangChainToolWrapper) - if not ( - hasattr(self, "skip_forward_signature_validation") - and getattr(self, "skip_forward_signature_validation") is True - ): - signature = inspect.signature(self.forward) - actual_keys = set(key for key in signature.parameters.keys() if key != "self") - expected_keys = set(self.inputs.keys()) - if actual_keys != expected_keys: - raise Exception( - f"In tool '{self.name}', 'forward' method parameters were {actual_keys}, but expected {expected_keys}. " - f"It should take 'self' as its first argument, then its next arguments should match the keys of tool attribute 'inputs'." - ) - - json_schema = _convert_type_hints_to_json_schema(self.forward, error_on_missing_type_hints=False)[ - "properties" - ] # This function will not raise an error on missing docstrings, contrary to get_json_schema - for key, value in self.inputs.items(): - assert key in json_schema, ( - f"Input '{key}' should be present in function signature, found only {json_schema.keys()}" - ) - if "nullable" in value: - assert "nullable" in json_schema[key], ( - f"Nullable argument '{key}' in inputs should have key 'nullable' set to True in function signature." - ) - if key in json_schema and "nullable" in json_schema[key]: - assert "nullable" in value, ( - f"Nullable argument '{key}' in function signature should have key 'nullable' set to True in inputs." - ) - - def forward(self, *args, **kwargs): - raise NotImplementedError("Write this method in your subclass of `Tool`.") - - def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): - if not self.is_initialized: - self.setup() - - # Handle the arguments might be passed as a single dictionary - if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], dict): - potential_kwargs = args[0] - - # If the dictionary keys match our input parameters, convert it to kwargs - if all(key in self.inputs for key in potential_kwargs): - args = () - kwargs = potential_kwargs - - if sanitize_inputs_outputs: - args, kwargs = handle_agent_input_types(*args, **kwargs) - outputs = self.forward(*args, **kwargs) - if sanitize_inputs_outputs: - outputs = handle_agent_output_types(outputs, self.output_type) - return outputs - - def setup(self): - """ - Overwrite this method here for any operation that is expensive and needs to be executed before you start using - your tool. Such as loading a big model. - """ - self.is_initialized = True - - def to_code_prompt(self) -> str: - args_signature = ", ".join(f"{arg_name}: {arg_schema['type']}" for arg_name, arg_schema in self.inputs.items()) - - # Use dict type for tools with output schema to indicate structured return - has_schema = hasattr(self, "output_schema") and self.output_schema is not None - output_type = "dict" if has_schema else self.output_type - tool_signature = f"({args_signature}) -> {output_type}" - tool_doc = self.description - - # Add an important note for smaller models (e.g. Mistral Small, Gemma 3, etc.) to properly handle structured output. - if has_schema: - tool_doc += "\n\nImportant: This tool returns structured output! Use the JSON schema below to directly access fields like result['field_name']. NO print() statements needed to inspect the output!" - - # Add arguments documentation - if self.inputs: - args_descriptions = "\n".join( - f"{arg_name}: {arg_schema['description']}" for arg_name, arg_schema in self.inputs.items() - ) - args_doc = f"Args:\n{textwrap.indent(args_descriptions, ' ')}" - tool_doc += f"\n\n{args_doc}" - - # Add returns documentation with output schema if it exists - if has_schema: - formatted_schema = json.dumps(self.output_schema, indent=4) - indented_schema = textwrap.indent(formatted_schema, " ") - returns_doc = f"\nReturns:\n dict (structured output): This tool ALWAYS returns a dictionary that strictly adheres to the following JSON schema:\n{indented_schema}" - tool_doc += f"\n{returns_doc}" - - tool_doc = f'"""{tool_doc}\n"""' - return f"def {self.name}{tool_signature}:\n{textwrap.indent(tool_doc, ' ')}" - - def to_tool_calling_prompt(self) -> str: - return f"{self.name}: {self.description}\n Takes inputs: {self.inputs}\n Returns an output of type: {self.output_type}" - - def to_dict(self) -> dict: - """Returns a dictionary representing the tool""" - class_name = self.__class__.__name__ - if type(self).__name__ == "SimpleTool": - # Check that imports are self-contained - source_code = get_source(self.forward).replace("@tool", "") - forward_node = ast.parse(source_code) - # If tool was created using '@tool' decorator, it has only a forward pass, so it's simpler to just get its code - method_checker = MethodChecker(set()) - method_checker.visit(forward_node) - - if len(method_checker.errors) > 0: - errors = [f"- {error}" for error in method_checker.errors] - raise (ValueError(f"SimpleTool validation failed for {self.name}:\n" + "\n".join(errors))) - - forward_source_code = get_source(self.forward) - tool_code = textwrap.dedent( - f""" - from smolagents import Tool - from typing import Any, Optional - - class {class_name}(Tool): - name = "{self.name}" - description = {json.dumps(textwrap.dedent(self.description).strip())} - inputs = {repr(self.inputs)} - output_type = "{self.output_type}" - """ - ).strip() - - # Add output_schema if it exists - if hasattr(self, "output_schema") and self.output_schema is not None: - tool_code += f"\n output_schema = {repr(self.output_schema)}" - import re - - def add_self_argument(source_code: str) -> str: - """Add 'self' as first argument to a function definition if not present.""" - pattern = r"def forward\(((?!self)[^)]*)\)" - - def replacement(match): - args = match.group(1).strip() - if args: # If there are other arguments - return f"def forward(self, {args})" - return "def forward(self)" - - return re.sub(pattern, replacement, source_code) - - forward_source_code = forward_source_code.replace(self.name, "forward") - forward_source_code = add_self_argument(forward_source_code) - forward_source_code = forward_source_code.replace("@tool", "").strip() - tool_code += "\n\n" + textwrap.indent(forward_source_code, " ") - - else: # If the tool was not created by the @tool decorator, it was made by subclassing Tool - if type(self).__name__ in [ - "SpaceToolWrapper", - "LangChainToolWrapper", - "GradioToolWrapper", - ]: - raise ValueError( - "Cannot save objects created with from_space, from_langchain or from_gradio, as this would create errors." - ) - - validate_tool_attributes(self.__class__) - - tool_code = "from typing import Any, Optional\n" + instance_to_source(self, base_cls=Tool) - - requirements = {el for el in get_imports(tool_code) if el not in sys.stdlib_module_names} | {"smolagents"} - - tool_dict = {"name": self.name, "code": tool_code, "requirements": sorted(requirements)} - - # Add output_schema if it exists - if hasattr(self, "output_schema") and self.output_schema is not None: - tool_dict["output_schema"] = self.output_schema - - return tool_dict - - @classmethod - def from_dict(cls, tool_dict: dict[str, Any], **kwargs) -> "Tool": - """ - Create tool from a dictionary representation. - - Args: - tool_dict (`dict[str, Any]`): Dictionary representation of the tool. - **kwargs: Additional keyword arguments to pass to the tool's constructor. - - Returns: - `Tool`: Tool object. - """ - if "code" not in tool_dict: - raise ValueError("Tool dictionary must contain 'code' key with the tool source code") - - tool = cls.from_code(tool_dict["code"], **kwargs) - - # Set output_schema if it exists in the dictionary - if "output_schema" in tool_dict: - tool.output_schema = tool_dict["output_schema"] - - return tool - - def save(self, output_dir: str | Path, tool_file_name: str = "tool", make_gradio_app: bool = True): - """ - Saves the relevant code files for your tool so it can be pushed to the Hub. This will copy the code of your - tool in `output_dir` as well as autogenerate: - - - a `{tool_file_name}.py` file containing the logic for your tool. - If you pass `make_gradio_app=True`, this will also write: - - an `app.py` file providing a UI for your tool when it is exported to a Space with `tool.push_to_hub()` - - a `requirements.txt` containing the names of the modules used by your tool (as detected when inspecting its - code) - - Args: - output_dir (`str` or `Path`): The folder in which you want to save your tool. - tool_file_name (`str`, *optional*): The file name in which you want to save your tool. - make_gradio_app (`bool`, *optional*, defaults to True): Whether to also export a `requirements.txt` file and Gradio UI. - """ - # Ensure output directory exists - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - # Save tool file - self._write_file(output_path / f"{tool_file_name}.py", self._get_tool_code()) - if make_gradio_app: - # Save app file - self._write_file(output_path / "app.py", self._get_gradio_app_code(tool_module_name=tool_file_name)) - # Save requirements file - self._write_file(output_path / "requirements.txt", self._get_requirements()) - - def _write_file(self, file_path: Path, content: str) -> None: - """Writes content to a file with UTF-8 encoding.""" - file_path.write_text(content, encoding="utf-8") - - def push_to_hub( - self, - repo_id: str, - commit_message: str = "Upload tool", - private: bool | None = None, - token: bool | str | None = None, - create_pr: bool = False, - ) -> str: - """ - Upload the tool to the Hub. - - Parameters: - repo_id (`str`): - The name of the repository you want to push your tool to. It should contain your organization name when - pushing to a given organization. - commit_message (`str`, *optional*, defaults to `"Upload tool"`): - Message to commit while pushing. - private (`bool`, *optional*): - Whether to make the repo private. If `None` (default), the repo will be public unless the organization's default is private. This value is ignored if the repo already exists. - token (`bool` or `str`, *optional*): - The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated - when running `huggingface-cli login` (stored in `~/.huggingface`). - create_pr (`bool`, *optional*, defaults to `False`): - Whether to create a PR with the uploaded files or directly commit. - """ - # Initialize repository - repo_id = self._initialize_hub_repo(repo_id, token, private) - # Prepare files for commit - additions = self._prepare_hub_files() - # Create commit - return create_commit( - repo_id=repo_id, - operations=additions, - commit_message=commit_message, - token=token, - create_pr=create_pr, - repo_type="space", - ) - - @staticmethod - def _initialize_hub_repo(repo_id: str, token: bool | str | None, private: bool | None) -> str: - """Initialize repository on Hugging Face Hub.""" - repo_url = create_repo( - repo_id=repo_id, - token=token, - private=private, - exist_ok=True, - repo_type="space", - space_sdk="gradio", - ) - metadata_update(repo_url.repo_id, {"tags": ["smolagents", "tool"]}, repo_type="space", token=token) - return repo_url.repo_id - - def _prepare_hub_files(self) -> list: - """Prepare files for Hub commit.""" - additions = [ - # Add tool code - CommitOperationAdd( - path_in_repo="tool.py", - path_or_fileobj=self._get_tool_code().encode(), - ), - # Add Gradio app - CommitOperationAdd( - path_in_repo="app.py", - path_or_fileobj=self._get_gradio_app_code().encode(), - ), - # Add requirements - CommitOperationAdd( - path_in_repo="requirements.txt", - path_or_fileobj=self._get_requirements().encode(), - ), - ] - return additions - - def _get_tool_code(self) -> str: - """Get the tool's code.""" - return self.to_dict()["code"] - - def _get_gradio_app_code(self, tool_module_name: str = "tool") -> str: - """Get the Gradio app code.""" - class_name = self.__class__.__name__ - return textwrap.dedent( - f"""\ - from smolagents import launch_gradio_demo - from {tool_module_name} import {class_name} - - tool = {class_name}() - launch_gradio_demo(tool) - """ - ) - - def _get_requirements(self) -> str: - """Get the requirements.""" - return "\n".join(self.to_dict()["requirements"]) - - @classmethod - def from_hub( - cls, - repo_id: str, - token: str | None = None, - trust_remote_code: bool = False, - **kwargs, - ): - """ - Loads a tool defined on the Hub. - - - - Loading a tool from the Hub means that you'll download the tool and execute it locally. - ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when - installing a package using pip/npm/apt. - - - - Args: - repo_id (`str`): - The name of the Space repo on the Hub where your tool is defined. - token (`str`, *optional*): - The token to identify you on hf.co. If unset, will use the token generated when running - `huggingface-cli login` (stored in `~/.huggingface`). - trust_remote_code(`str`, *optional*, defaults to False): - This flags marks that you understand the risk of running remote code and that you trust this tool. - If not setting this to True, loading the tool from Hub will fail. - kwargs (additional keyword arguments, *optional*): - Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as - `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the - others will be passed along to its init. - """ - if not trust_remote_code: - raise ValueError( - "Loading a tool from Hub requires to acknowledge you trust its code: to do so, pass `trust_remote_code=True`." - ) - - # Get the tool's tool.py file. - tool_file = hf_hub_download( - repo_id, - "tool.py", - token=token, - repo_type="space", - cache_dir=kwargs.get("cache_dir"), - force_download=kwargs.get("force_download"), - proxies=kwargs.get("proxies"), - revision=kwargs.get("revision"), - subfolder=kwargs.get("subfolder"), - local_files_only=kwargs.get("local_files_only"), - ) - - tool_code = Path(tool_file).read_text() - return Tool.from_code(tool_code, **kwargs) - - @classmethod - def from_code(cls, tool_code: str, **kwargs): - module = types.ModuleType("dynamic_tool") - - exec(tool_code, module.__dict__) - - # Find the Tool subclass - tool_class = next( - ( - obj - for _, obj in inspect.getmembers(module, inspect.isclass) - if issubclass(obj, Tool) and obj is not Tool - ), - None, - ) - - if tool_class is None: - raise ValueError("No Tool subclass found in the code.") - - if not isinstance(tool_class.inputs, dict): - tool_class.inputs = ast.literal_eval(tool_class.inputs) - - # Handle output_schema if it exists and is a string representation - if hasattr(tool_class, "output_schema") and isinstance(tool_class.output_schema, str): - tool_class.output_schema = ast.literal_eval(tool_class.output_schema) - - return tool_class(**kwargs) - - @staticmethod - def from_space( - space_id: str, - name: str, - description: str = "", - api_name: str | None = None, - token: str | None = None, - ): - """ - Creates a [`Tool`] from a Space given its id on the Hub. - - Args: - space_id (`str`): - The id of the Space on the Hub. - name (`str`): - The name of the tool. - description (`str`): - The description of the tool. - api_name (`str`, *optional*): - The specific api_name to use, if the space has several tabs. If not precised, will default to the first available api. - token (`str`, *optional*): - Add your token to access private spaces or increase your GPU quotas. - Returns: - [`Tool`]: - The Space, as a tool. - - Examples: - ```py - >>> image_generator = Tool.from_space( - ... space_id="black-forest-labs/FLUX.1-schnell", - ... name="image-generator", - ... description="Generate an image from a prompt" - ... ) - >>> image = image_generator("Generate an image of a cool surfer in Tahiti") - ``` - ```py - >>> face_swapper = Tool.from_space( - ... "tuan2308/face-swap", - ... "face_swapper", - ... "Tool that puts the face shown on the first image on the second image. You can give it paths to images.", - ... ) - >>> image = face_swapper('./aymeric.jpeg', './ruth.jpg') - ``` - """ - from gradio_client import Client, handle_file - - class SpaceToolWrapper(Tool): - skip_forward_signature_validation = True - - def __init__( - self, - space_id: str, - name: str, - description: str = "", - api_name: str | None = None, - token: str | None = None, - ): - self.name = name - self.description = description - self.client = Client(space_id, hf_token=token) - space_api = self.client.view_api(return_format="dict", print_info=False) - assert isinstance(space_api, dict) - space_description = space_api["named_endpoints"] - - # If api_name is not defined, take the first of the available APIs for this space - if api_name is None: - api_name = list(space_description.keys())[0] - warnings.warn( - f"Since `api_name` was not defined, it was automatically set to the first available API: `{api_name}`." - ) - self.api_name = api_name - - try: - space_description_api = space_description[api_name] - except KeyError: - raise KeyError(f"Could not find specified {api_name=} among available api names.") - self.inputs = {} - for parameter in space_description_api["parameters"]: - parameter_type = parameter["type"]["type"] - if parameter_type == "object": - parameter_type = "any" - self.inputs[parameter["parameter_name"]] = { - "type": parameter_type, - "description": parameter["python_type"]["description"], - "nullable": parameter["parameter_has_default"], - } - output_component = space_description_api["returns"][0]["component"] - if output_component == "Image": - self.output_type = "image" - elif output_component == "Audio": - self.output_type = "audio" - else: - self.output_type = "any" - self.is_initialized = True - - def sanitize_argument_for_prediction(self, arg): - from gradio_client.utils import is_http_url_like - from PIL.Image import Image - - if isinstance(arg, Image): - temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False) - arg.save(temp_file.name) - arg = temp_file.name - if ( - (isinstance(arg, str) and os.path.isfile(arg)) - or (isinstance(arg, Path) and arg.exists() and arg.is_file()) - or is_http_url_like(arg) - ): - arg = handle_file(arg) - return arg - - def forward(self, *args, **kwargs): - # Preprocess args and kwargs: - args = list(args) - for i, arg in enumerate(args): - args[i] = self.sanitize_argument_for_prediction(arg) - for arg_name, arg in kwargs.items(): - kwargs[arg_name] = self.sanitize_argument_for_prediction(arg) - - output = self.client.predict(*args, api_name=self.api_name, **kwargs) - if isinstance(output, tuple) or isinstance(output, list): - if isinstance(output[1], str): - raise ValueError("The space returned this message: " + output[1]) - output = output[ - 0 - ] # Sometime the space also returns the generation seed, in which case the result is at index 0 - IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"] - AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".flac"] - if isinstance(output, str) and any([output.endswith(ext) for ext in IMAGE_EXTENSIONS]): - output = AgentImage(output) - elif isinstance(output, str) and any([output.endswith(ext) for ext in AUDIO_EXTENSIONS]): - output = AgentAudio(output) - return output - - return SpaceToolWrapper( - space_id=space_id, - name=name, - description=description, - api_name=api_name, - token=token, - ) - - @staticmethod - def from_gradio(gradio_tool): - """ - Creates a [`Tool`] from a gradio tool. - """ - import inspect - - class GradioToolWrapper(Tool): - def __init__(self, _gradio_tool): - self.name = _gradio_tool.name - self.description = _gradio_tool.description - self.output_type = "string" - self._gradio_tool = _gradio_tool - func_args = list(inspect.signature(_gradio_tool.run).parameters.items()) - self.inputs = { - key: {"type": CONVERSION_DICT[value.annotation], "description": ""} for key, value in func_args - } - self.forward = self._gradio_tool.run - - return GradioToolWrapper(gradio_tool) - - @staticmethod - def from_langchain(langchain_tool): - """ - Creates a [`Tool`] from a langchain tool. - """ - - class LangChainToolWrapper(Tool): - skip_forward_signature_validation = True - - def __init__(self, _langchain_tool): - self.name = _langchain_tool.name.lower() - self.description = _langchain_tool.description - self.inputs = _langchain_tool.args.copy() - for input_content in self.inputs.values(): - if "title" in input_content: - input_content.pop("title") - input_content["description"] = "" - self.output_type = "string" - self.langchain_tool = _langchain_tool - self.is_initialized = True - - def forward(self, *args, **kwargs): - tool_input = kwargs.copy() - for index, argument in enumerate(args): - if index < len(self.inputs): - input_key = next(iter(self.inputs)) - tool_input[input_key] = argument - return self.langchain_tool.run(tool_input) - - return LangChainToolWrapper(langchain_tool) - - -def launch_gradio_demo(tool: Tool): - """ - Launches a gradio demo for a tool. The corresponding tool class needs to properly implement the class attributes - `inputs` and `output_type`. - - Args: - tool (`Tool`): The tool for which to launch the demo. - """ - try: - import gradio as gr - except ImportError: - raise ImportError("Gradio should be installed in order to launch a gradio demo.") - - TYPE_TO_COMPONENT_CLASS_MAPPING = { - "boolean": gr.Checkbox, - "image": gr.Image, - "audio": gr.Audio, - "string": gr.Textbox, - "integer": gr.Number, - "number": gr.Number, - } - - def tool_forward(*args, **kwargs): - return tool(*args, sanitize_inputs_outputs=True, **kwargs) - - tool_forward.__signature__ = inspect.signature(tool.forward) - - gradio_inputs = [] - for input_name, input_details in tool.inputs.items(): - input_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[input_details["type"]] - new_component = input_gradio_component_class(label=input_name) - gradio_inputs.append(new_component) - - output_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[tool.output_type] - gradio_output = output_gradio_component_class(label="Output") - - gr.Interface( - fn=tool_forward, - inputs=gradio_inputs, - outputs=gradio_output, - title=tool.name, - description=tool.description, - api_name=tool.name, - ).launch() - - -def load_tool( - repo_id, - model_repo_id: str | None = None, - token: str | None = None, - trust_remote_code: bool = False, - **kwargs, -): - """ - Main function to quickly load a tool from the Hub. - - - - Loading a tool means that you'll download the tool and execute it locally. - ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when - installing a package using pip/npm/apt. - - - - Args: - repo_id (`str`): - Space repo ID of a tool on the Hub. - model_repo_id (`str`, *optional*): - Use this argument to use a different model than the default one for the tool you selected. - token (`str`, *optional*): - The token to identify you on hf.co. If unset, will use the token generated when running `huggingface-cli - login` (stored in `~/.huggingface`). - trust_remote_code (`bool`, *optional*, defaults to False): - This needs to be accepted in order to load a tool from Hub. - kwargs (additional keyword arguments, *optional*): - Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as - `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the others - will be passed along to its init. - """ - return Tool.from_hub( - repo_id, - model_repo_id=model_repo_id, - token=token, - trust_remote_code=trust_remote_code, - **kwargs, - ) - - -def add_description(description): - """ - A decorator that adds a description to a function. - """ - - def inner(func): - func.description = description - func.name = func.__name__ - return func - - return inner - - -class ToolCollection: - """ - Tool collections enable loading a collection of tools in the agent's toolbox. - - Collections can be loaded from a collection in the Hub or from an MCP server, see: - - [`ToolCollection.from_hub`] - - [`ToolCollection.from_mcp`] - - For example and usage, see: [`ToolCollection.from_hub`] and [`ToolCollection.from_mcp`] - """ - - def __init__(self, tools: list[Tool]): - self.tools = tools - - @classmethod - def from_hub( - cls, - collection_slug: str, - token: str | None = None, - trust_remote_code: bool = False, - ) -> "ToolCollection": - """Loads a tool collection from the Hub. - - it adds a collection of tools from all Spaces in the collection to the agent's toolbox - - > [!NOTE] - > Only Spaces will be fetched, so you can feel free to add models and datasets to your collection if you'd - > like for this collection to showcase them. - - Args: - collection_slug (str): The collection slug referencing the collection. - token (str, *optional*): The authentication token if the collection is private. - trust_remote_code (bool, *optional*, defaults to False): Whether to trust the remote code. - - Returns: - ToolCollection: A tool collection instance loaded with the tools. - - Example: - ```py - >>> from smolagents import ToolCollection, CodeAgent - - >>> image_tool_collection = ToolCollection.from_hub("huggingface-tools/diffusion-tools-6630bb19a942c2306a2cdb6f") - >>> agent = CodeAgent(tools=[*image_tool_collection.tools], add_base_tools=True) - - >>> agent.run("Please draw me a picture of rivers and lakes.") - ``` - """ - _collection = get_collection(collection_slug, token=token) - _hub_repo_ids = {item.item_id for item in _collection.items if item.item_type == "space"} - - tools = [Tool.from_hub(repo_id, token, trust_remote_code) for repo_id in _hub_repo_ids] - - return cls(tools) - - @classmethod - @contextmanager - def from_mcp( - cls, - server_parameters: "mcp.StdioServerParameters" | dict, - trust_remote_code: bool = False, - structured_output: bool | None = None, - ) -> "ToolCollection": - """Automatically load a tool collection from an MCP server. - - This method supports Stdio, Streamable HTTP, and legacy HTTP+SSE MCP servers. Look at the `server_parameters` - argument for more details on how to connect to each MCP server. - - Note: a separate thread will be spawned to run an asyncio event loop handling - the MCP server. - - Args: - server_parameters (`mcp.StdioServerParameters` or `dict`): - Configuration parameters to connect to the MCP server. This can be: - - - An instance of `mcp.StdioServerParameters` for connecting a Stdio MCP server via standard input/output using a subprocess. - - - A `dict` with at least: - - "url": URL of the server. - - "transport": Transport protocol to use, one of: - - "streamable-http": Streamable HTTP transport (default). - - "sse": Legacy HTTP+SSE transport (deprecated). - trust_remote_code (`bool`, *optional*, defaults to `False`): - Whether to trust the execution of code from tools defined on the MCP server. - This option should only be set to `True` if you trust the MCP server, - and understand the risks associated with running remote code on your local machine. - If set to `False`, loading tools from MCP will fail. - structured_output (`bool`, *optional*, defaults to `False`): - Whether to enable structured output features for MCP tools. If True, enables: - - Support for outputSchema in MCP tools - - Structured content handling (structuredContent from MCP responses) - - JSON parsing fallback for structured data - If False, uses the original simple text-only behavior for backwards compatibility. - - Returns: - ToolCollection: A tool collection instance. - - Example with a Stdio MCP server: - ```py - >>> import os - >>> from smolagents import ToolCollection, CodeAgent, InferenceClientModel - >>> from mcp import StdioServerParameters - - >>> model = InferenceClientModel() - - >>> server_parameters = StdioServerParameters( - >>> command="uvx", - >>> args=["--quiet", "pubmedmcp@0.1.3"], - >>> env={"UV_PYTHON": "3.12", **os.environ}, - >>> ) - - >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - - Example with structured output enabled: - ```py - >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True, structured_output=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - - Example with a Streamable HTTP MCP server: - ```py - >>> with ToolCollection.from_mcp({"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - """ - # Handle future warning for structured_output default value change - if structured_output is None: - warnings.warn( - "Parameter 'structured_output' was not specified. " - "Currently it defaults to False, but in version 1.25, the default will change to True. " - "To suppress this warning, explicitly set structured_output=True (new behavior) or structured_output=False (legacy behavior). " - "See documentation at https://huggingface.co/docs/smolagents/tutorials/tools#structured-output-and-output-schema-support for more details.", - FutureWarning, - stacklevel=2, - ) - structured_output = False - - try: - from mcpadapt.core import MCPAdapt - from mcpadapt.smolagents_adapter import SmolAgentsAdapter - except ImportError: - raise ImportError( - """Please install 'mcp' extra to use ToolCollection.from_mcp: `pip install 'smolagents[mcp]'`.""" - ) - if isinstance(server_parameters, dict): - transport = server_parameters.get("transport") - if transport is None: - transport = "streamable-http" - server_parameters["transport"] = transport - if transport not in {"sse", "streamable-http"}: - raise ValueError( - f"Unsupported transport: {transport}. Supported transports are 'streamable-http' and 'sse'." - ) - if not trust_remote_code: - raise ValueError( - "Loading tools from MCP requires you to acknowledge you trust the MCP server, " - "as it will execute code on your local machine: pass `trust_remote_code=True`." - ) - with MCPAdapt(server_parameters, SmolAgentsAdapter(structured_output=structured_output)) as tools: - yield cls(tools) - - -def tool(tool_function: Callable) -> Tool: - """ - Convert a function into an instance of a dynamically created Tool subclass. - - Args: - tool_function (`Callable`): Function to convert into a Tool subclass. - Should have type hints for each input and a type hint for the output. - Should also have a docstring including the description of the function - and an 'Args:' part where each argument is described. - """ - tool_json_schema = get_json_schema(tool_function)["function"] - if "return" not in tool_json_schema: - if len(tool_json_schema["parameters"]["properties"]) == 0: - tool_json_schema["return"] = {"type": "null"} - else: - raise TypeHintParsingException( - "Tool return type not found: make sure your function has a return type hint!" - ) - - class SimpleTool(Tool): - def __init__(self): - self.is_initialized = True - - # Set the class attributes - SimpleTool.name = tool_json_schema["name"] - SimpleTool.description = tool_json_schema["description"] - SimpleTool.inputs = tool_json_schema["parameters"]["properties"] - SimpleTool.output_type = tool_json_schema["return"]["type"] - - # Set output_schema if it exists in the JSON schema - if "output_schema" in tool_json_schema: - SimpleTool.output_schema = tool_json_schema["output_schema"] - elif "return" in tool_json_schema and "schema" in tool_json_schema["return"]: - SimpleTool.output_schema = tool_json_schema["return"]["schema"] - - @wraps(tool_function) - def wrapped_function(*args, **kwargs): - return tool_function(*args, **kwargs) - - # Bind the copied function to the forward method - SimpleTool.forward = staticmethod(wrapped_function) - - # Get the signature parameters of the tool function - sig = inspect.signature(tool_function) - # - Add "self" as first parameter to tool_function signature - new_sig = sig.replace( - parameters=[inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] + list(sig.parameters.values()) - ) - # - Set the signature of the forward method - SimpleTool.forward.__signature__ = new_sig - - # Create and attach the source code of the dynamically created tool class and forward method - # - Get the source code of tool_function - tool_source = textwrap.dedent(inspect.getsource(tool_function)) - # - Remove the tool decorator and function definition line - lines = tool_source.splitlines() - tree = ast.parse(tool_source) - # - Find function definition - func_node = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)), None) - if not func_node: - raise ValueError( - f"No function definition found in the provided source of {tool_function.__name__}. " - "Ensure the input is a standard function." - ) - # - Extract decorator lines - decorator_lines = "" - if func_node.decorator_list: - tool_decorators = [d for d in func_node.decorator_list if isinstance(d, ast.Name) and d.id == "tool"] - if len(tool_decorators) > 1: - raise ValueError( - f"Multiple @tool decorators found on function '{func_node.name}'. Only one @tool decorator is allowed." - ) - if len(tool_decorators) < len(func_node.decorator_list): - warnings.warn( - f"Function '{func_node.name}' has decorators other than @tool. " - "This may cause issues with serialization in the remote executor. See issue #1626." - ) - decorator_start = tool_decorators[0].end_lineno if tool_decorators else 0 - decorator_end = func_node.decorator_list[-1].end_lineno - decorator_lines = "\n".join(lines[decorator_start:decorator_end]) - # - Extract tool source body - body_start = func_node.body[0].lineno - 1 # AST lineno starts at 1 - tool_source_body = "\n".join(lines[body_start:]) - # - Create the forward method source, including def line and indentation - forward_method_source = f"def forward{new_sig}:\n{tool_source_body}" - # - Create the class source - indent = " " * 4 # for class method - class_source = ( - textwrap.dedent(f""" - class SimpleTool(Tool): - name: str = "{tool_json_schema["name"]}" - description: str = {json.dumps(textwrap.dedent(tool_json_schema["description"]).strip())} - inputs: dict[str, dict[str, str]] = {tool_json_schema["parameters"]["properties"]} - output_type: str = "{tool_json_schema["return"]["type"]}" - - def __init__(self): - self.is_initialized = True - - """) - + textwrap.indent(decorator_lines, indent) - + textwrap.indent(forward_method_source, indent) - ) - # - Store the source code on both class and method for inspection - SimpleTool.__source__ = class_source - SimpleTool.forward.__source__ = forward_method_source - - simple_tool = SimpleTool() - return simple_tool - - -class PipelineTool(Tool): - """ - A [`Tool`] tailored towards Transformer models. On top of the class attributes of the base class [`Tool`], you will - need to specify: - - - **model_class** (`type`) -- The class to use to load the model in this tool. - - **default_checkpoint** (`str`) -- The default checkpoint that should be used when the user doesn't specify one. - - **pre_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the - pre-processor - - **post_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the - post-processor (when different from the pre-processor). - - Args: - model (`str` or [`transformers.PreTrainedModel`], *optional*): - The name of the checkpoint to use for the model, or the instantiated model. If unset, will default to the - value of the class attribute `default_checkpoint`. - pre_processor (`str` or `Any`, *optional*): - The name of the checkpoint to use for the pre-processor, or the instantiated pre-processor (can be a - tokenizer, an image processor, a feature extractor or a processor). Will default to the value of `model` if - unset. - post_processor (`str` or `Any`, *optional*): - The name of the checkpoint to use for the post-processor, or the instantiated pre-processor (can be a - tokenizer, an image processor, a feature extractor or a processor). Will default to the `pre_processor` if - unset. - device (`int`, `str` or `torch.device`, *optional*): - The device on which to execute the model. Will default to any accelerator available (GPU, MPS etc...), the - CPU otherwise. - device_map (`str` or `dict`, *optional*): - If passed along, will be used to instantiate the model. - model_kwargs (`dict`, *optional*): - Any keyword argument to send to the model instantiation. - token (`str`, *optional*): - The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated when - running `huggingface-cli login` (stored in `~/.huggingface`). - hub_kwargs (additional keyword arguments, *optional*): - Any additional keyword argument to send to the methods that will load the data from the Hub. - """ - - pre_processor_class = None - model_class = None - post_processor_class = None - default_checkpoint = None - description = "This is a pipeline tool" - name = "pipeline" - inputs = {"prompt": str} - output_type = str - skip_forward_signature_validation = True - - def __init__( - self, - model=None, - pre_processor=None, - post_processor=None, - device=None, - device_map=None, - model_kwargs=None, - token=None, - **hub_kwargs, - ): - if not _is_package_available("accelerate") or not _is_package_available("torch"): - raise ModuleNotFoundError( - "Please install 'transformers' extra to use a PipelineTool: `pip install 'smolagents[transformers]'`" - ) - - if model is None: - if self.default_checkpoint is None: - raise ValueError("This tool does not implement a default checkpoint, you need to pass one.") - model = self.default_checkpoint - if pre_processor is None: - pre_processor = model - - self.model = model - self.pre_processor = pre_processor - self.post_processor = post_processor - self.device = device - self.device_map = device_map - self.model_kwargs = {} if model_kwargs is None else model_kwargs - if device_map is not None: - self.model_kwargs["device_map"] = device_map - self.hub_kwargs = hub_kwargs - self.hub_kwargs["token"] = token - - super().__init__() - - def setup(self): - """ - Instantiates the `pre_processor`, `model` and `post_processor` if necessary. - """ - if isinstance(self.pre_processor, str): - if self.pre_processor_class is None: - from transformers import AutoProcessor - - self.pre_processor_class = AutoProcessor - self.pre_processor = self.pre_processor_class.from_pretrained(self.pre_processor, **self.hub_kwargs) - - if isinstance(self.model, str): - self.model = self.model_class.from_pretrained(self.model, **self.model_kwargs, **self.hub_kwargs) - - if self.post_processor is None: - self.post_processor = self.pre_processor - elif isinstance(self.post_processor, str): - if self.post_processor_class is None: - from transformers import AutoProcessor - - self.post_processor_class = AutoProcessor - self.post_processor = self.post_processor_class.from_pretrained(self.post_processor, **self.hub_kwargs) - - if self.device is None: - if self.device_map is not None: - self.device = list(self.model.hf_device_map.values())[0] - else: - from accelerate import PartialState - - self.device = PartialState().default_device - - if self.device_map is None: - self.model.to(self.device) - - super().setup() - - def encode(self, raw_inputs): - """ - Uses the `pre_processor` to prepare the inputs for the `model`. - """ - return self.pre_processor(raw_inputs) - - def forward(self, inputs): - """ - Sends the inputs through the `model`. - """ - import torch - - with torch.no_grad(): - return self.model(**inputs) - - def decode(self, outputs): - """ - Uses the `post_processor` to decode the model output. - """ - return self.post_processor(outputs) - - def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): - import torch - from accelerate.utils import send_to_device - - if not self.is_initialized: - self.setup() - - if sanitize_inputs_outputs: - args, kwargs = handle_agent_input_types(*args, **kwargs) - encoded_inputs = self.encode(*args, **kwargs) - - tensor_inputs = {k: v for k, v in encoded_inputs.items() if isinstance(v, torch.Tensor)} - non_tensor_inputs = {k: v for k, v in encoded_inputs.items() if not isinstance(v, torch.Tensor)} - - encoded_inputs = send_to_device(tensor_inputs, self.device) - outputs = self.forward({**encoded_inputs, **non_tensor_inputs}) - outputs = send_to_device(outputs, "cpu") - decoded_outputs = self.decode(outputs) - if sanitize_inputs_outputs: - decoded_outputs = handle_agent_output_types(decoded_outputs, self.output_type) - return decoded_outputs - - -def get_tools_definition_code(tools: dict[str, Tool]) -> str: - tool_codes = [] - for tool in tools.values(): - validate_tool_attributes(tool.__class__, check_imports=False) - tool_code = instance_to_source(tool, base_cls=Tool) - tool_code = tool_code.replace("from smolagents.tools import Tool", "") - tool_code += f"\n\n{tool.name} = {tool.__class__.__name__}()\n" - tool_codes.append(tool_code) - - tool_definition_code = "\n".join([f"import {module}" for module in BASE_BUILTIN_MODULES]) - tool_definition_code += textwrap.dedent( - """ - from typing import Any - - class Tool: - def __call__(self, *args, **kwargs): - return self.forward(*args, **kwargs) - - def forward(self, *args, **kwargs): - pass # to be implemented in child class - """ - ) - tool_definition_code += "\n\n".join(tool_codes) - return tool_definition_code - - -def validate_tool_arguments(tool: Tool, arguments: Any) -> None: - """Validate tool arguments against tool's input schema. - - Checks that all provided arguments match the tool's expected input types and that - all required arguments are present. Supports both dictionary arguments and single - value arguments for tools with one input parameter. - - Args: - tool (`Tool`): Tool whose input schema will be used for validation. - arguments (`Any`): Arguments to validate. Can be a dictionary mapping - argument names to values, or a single value for tools with one input. - - - Raises: - ValueError: If an argument is not in the tool's input schema, if a required - argument is missing, or if the argument value doesn't match the expected type. - TypeError: If an argument has an incorrect type that cannot be converted - (e.g., string instead of number, excluding integer to number conversion). - - Note: - - Supports type coercion from integer to number - - Handles nullable parameters when explicitly marked in the schema - - Accepts "any" type as a wildcard that matches all types - """ - if isinstance(arguments, dict): - for key, value in arguments.items(): - if key not in tool.inputs: - raise ValueError(f"Argument {key} is not in the tool's input schema") - - actual_type = _get_json_schema_type(type(value))["type"] - expected_type = tool.inputs[key]["type"] - expected_type_is_nullable = tool.inputs[key].get("nullable", False) - - # Type is valid if it matches, is "any", or is null for nullable parameters - if ( - (actual_type != expected_type if isinstance(expected_type, str) else actual_type not in expected_type) - and expected_type != "any" - and not (actual_type == "null" and expected_type_is_nullable) - ): - if actual_type == "integer" and expected_type == "number": - continue - raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'") - - for key, schema in tool.inputs.items(): - key_is_nullable = schema.get("nullable", False) - if key not in arguments and not key_is_nullable: - raise ValueError(f"Argument {key} is required") - return None - else: - expected_type = list(tool.inputs.values())[0]["type"] - if _get_json_schema_type(type(arguments))["type"] != expected_type and not expected_type == "any": - raise TypeError(f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'") - - -__all__ = [ - "AUTHORIZED_TYPES", - "Tool", - "tool", - "load_tool", - "launch_gradio_demo", - "ToolCollection", +#!/usr/bin/env python +# coding=utf-8 + +# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import ast +import inspect +import json +import logging +import os +import sys +import tempfile +import textwrap +import types +import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from huggingface_hub import ( + CommitOperationAdd, + create_commit, + create_repo, + get_collection, + hf_hub_download, + metadata_update, +) + +from ._function_type_hints_utils import ( + TypeHintParsingException, + _convert_type_hints_to_json_schema, + _get_json_schema_type, + get_imports, + get_json_schema, +) +from .agent_types import AgentAudio, AgentImage, handle_agent_input_types, handle_agent_output_types +from .tool_validation import MethodChecker, validate_tool_attributes +from .utils import ( + BASE_BUILTIN_MODULES, + _is_package_available, + get_source, + instance_to_source, + is_valid_name, +) + + +if TYPE_CHECKING: + import mcp + + +logger = logging.getLogger(__name__) + + +def validate_after_init(cls): + original_init = cls.__init__ + + @wraps(original_init) + def new_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + self.validate_arguments() + + cls.__init__ = new_init + return cls + + +AUTHORIZED_TYPES = [ + "string", + "boolean", + "integer", + "number", + "image", + "audio", + "array", + "object", + "any", + "null", +] + +CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} + + +class BaseTool(ABC): + name: str + + @abstractmethod + def __call__(self, *args, **kwargs) -> Any: + pass + + +class Tool(BaseTool): + """ + A base class for the functions used by the agent. Subclass this and implement the `forward` method as well as the + following class attributes: + + - **description** (`str`) -- A short description of what your tool does, the inputs it expects and the output(s) it + will return. For instance 'This is a tool that downloads a file from a `url`. It takes the `url` as input, and + returns the text contained in the file'. + - **name** (`str`) -- A performative name that will be used for your tool in the prompt to the agent. For instance + `"text-classifier"` or `"image_generator"`. + - **inputs** (`Dict[str, Dict[str, Union[str, type, bool]]]`) -- The dict of modalities expected for the inputs. + It has one `type`key and a `description`key. + This is used by `launch_gradio_demo` or to make a nice space from your tool, and also can be used in the generated + description for your tool. + - **output_type** (`type`) -- The type of the tool output. This is used by `launch_gradio_demo` + or to make a nice space from your tool, and also can be used in the generated description for your tool. + - **output_schema** (`Dict[str, Any]`, *optional*) -- The JSON schema defining the expected structure of the tool output. + This can be included in system prompts to help agents understand the expected output format. Note: This is currently + used for informational purposes only and does not perform actual output validation. + + You can also override the method [`~Tool.setup`] if your tool has an expensive operation to perform before being + usable (such as loading a model). [`~Tool.setup`] will be called the first time you use your tool, but not at + instantiation. + """ + + name: str + description: str + inputs: dict[str, dict[str, str | type | bool]] + output_type: str + output_schema: dict[str, Any] | None = None + + def __init__(self, *args, **kwargs): + self.is_initialized = False + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + validate_after_init(cls) + + def validate_arguments(self): + required_attributes = { + "description": str, + "name": str, + "inputs": dict, + "output_type": str, + } + # Validate class attributes + for attr, expected_type in required_attributes.items(): + attr_value = getattr(self, attr, None) + if attr_value is None: + raise TypeError(f"You must set an attribute {attr}.") + if not isinstance(attr_value, expected_type): + raise TypeError( + f"Attribute {attr} should have type {expected_type.__name__}, got {type(attr_value)} instead." + ) + + # Validate optional output_schema attribute + output_schema = getattr(self, "output_schema", None) + if output_schema is not None and not isinstance(output_schema, dict): + raise TypeError(f"Attribute output_schema should have type dict, got {type(output_schema)} instead.") + + # - Validate name + if not is_valid_name(self.name): + raise Exception( + f"Invalid Tool name '{self.name}': must be a valid Python identifier and not a reserved keyword" + ) + # Validate inputs + for input_name, input_content in self.inputs.items(): + assert isinstance(input_content, dict), f"Input '{input_name}' should be a dictionary." + assert "type" in input_content and "description" in input_content, ( + f"Input '{input_name}' should have keys 'type' and 'description', has only {list(input_content.keys())}." + ) + # Get input_types as a list, whether from a string or list + if isinstance(input_content["type"], str): + input_types = [input_content["type"]] + elif isinstance(input_content["type"], list): + input_types = input_content["type"] + # Check if all elements are strings + if not all(isinstance(t, str) for t in input_types): + raise TypeError( + f"Input '{input_name}': when type is a list, all elements must be strings, got {input_content['type']}" + ) + else: + raise TypeError( + f"Input '{input_name}': type must be a string or list of strings, got {type(input_content['type']).__name__}" + ) + # Check all types are authorized + invalid_types = [t for t in input_types if t not in AUTHORIZED_TYPES] + if invalid_types: + raise ValueError(f"Input '{input_name}': types {invalid_types} must be one of {AUTHORIZED_TYPES}") + # Validate output type + assert getattr(self, "output_type", None) in AUTHORIZED_TYPES + + # Validate forward function signature, except for Tools that use a "generic" signature (PipelineTool, SpaceToolWrapper, LangChainToolWrapper) + if not ( + hasattr(self, "skip_forward_signature_validation") + and getattr(self, "skip_forward_signature_validation") is True + ): + signature = inspect.signature(self.forward) + actual_keys = set(key for key in signature.parameters.keys() if key != "self") + expected_keys = set(self.inputs.keys()) + if actual_keys != expected_keys: + raise Exception( + f"In tool '{self.name}', 'forward' method parameters were {actual_keys}, but expected {expected_keys}. " + f"It should take 'self' as its first argument, then its next arguments should match the keys of tool attribute 'inputs'." + ) + + json_schema = _convert_type_hints_to_json_schema(self.forward, error_on_missing_type_hints=False)[ + "properties" + ] # This function will not raise an error on missing docstrings, contrary to get_json_schema + for key, value in self.inputs.items(): + assert key in json_schema, ( + f"Input '{key}' should be present in function signature, found only {json_schema.keys()}" + ) + if "nullable" in value: + assert "nullable" in json_schema[key], ( + f"Nullable argument '{key}' in inputs should have key 'nullable' set to True in function signature." + ) + if key in json_schema and "nullable" in json_schema[key]: + assert "nullable" in value, ( + f"Nullable argument '{key}' in function signature should have key 'nullable' set to True in inputs." + ) + + def forward(self, *args, **kwargs): + raise NotImplementedError("Write this method in your subclass of `Tool`.") + + def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): + if not self.is_initialized: + self.setup() + + # Handle the arguments might be passed as a single dictionary + if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], dict): + potential_kwargs = args[0] + + # If the dictionary keys match our input parameters, convert it to kwargs + if all(key in self.inputs for key in potential_kwargs): + args = () + kwargs = potential_kwargs + + if sanitize_inputs_outputs: + args, kwargs = handle_agent_input_types(*args, **kwargs) + outputs = self.forward(*args, **kwargs) + if sanitize_inputs_outputs: + outputs = handle_agent_output_types(outputs, self.output_type) + return outputs + + def setup(self): + """ + Overwrite this method here for any operation that is expensive and needs to be executed before you start using + your tool. Such as loading a big model. + """ + self.is_initialized = True + + def to_code_prompt(self) -> str: + args_signature = ", ".join(f"{arg_name}: {arg_schema['type']}" for arg_name, arg_schema in self.inputs.items()) + + # Use dict type for tools with output schema to indicate structured return + has_schema = hasattr(self, "output_schema") and self.output_schema is not None + output_type = "dict" if has_schema else self.output_type + tool_signature = f"({args_signature}) -> {output_type}" + tool_doc = self.description + + # Add an important note for smaller models (e.g. Mistral Small, Gemma 3, etc.) to properly handle structured output. + if has_schema: + tool_doc += "\n\nImportant: This tool returns structured output! Use the JSON schema below to directly access fields like result['field_name']. NO print() statements needed to inspect the output!" + + # Add arguments documentation + if self.inputs: + args_descriptions = "\n".join( + f"{arg_name}: {arg_schema['description']}" for arg_name, arg_schema in self.inputs.items() + ) + args_doc = f"Args:\n{textwrap.indent(args_descriptions, ' ')}" + tool_doc += f"\n\n{args_doc}" + + # Add returns documentation with output schema if it exists + if has_schema: + formatted_schema = json.dumps(self.output_schema, indent=4) + indented_schema = textwrap.indent(formatted_schema, " ") + returns_doc = f"\nReturns:\n dict (structured output): This tool ALWAYS returns a dictionary that strictly adheres to the following JSON schema:\n{indented_schema}" + tool_doc += f"\n{returns_doc}" + + tool_doc = f'"""{tool_doc}\n"""' + return f"def {self.name}{tool_signature}:\n{textwrap.indent(tool_doc, ' ')}" + + def to_tool_calling_prompt(self) -> str: + return f"{self.name}: {self.description}\n Takes inputs: {self.inputs}\n Returns an output of type: {self.output_type}" + + def to_dict(self) -> dict: + """Returns a dictionary representing the tool""" + class_name = self.__class__.__name__ + if type(self).__name__ == "SimpleTool": + # Check that imports are self-contained + source_code = get_source(self.forward).replace("@tool", "") + forward_node = ast.parse(source_code) + # If tool was created using '@tool' decorator, it has only a forward pass, so it's simpler to just get its code + method_checker = MethodChecker(set()) + method_checker.visit(forward_node) + + if len(method_checker.errors) > 0: + errors = [f"- {error}" for error in method_checker.errors] + raise (ValueError(f"SimpleTool validation failed for {self.name}:\n" + "\n".join(errors))) + + forward_source_code = get_source(self.forward) + tool_code = textwrap.dedent( + f""" + from smolagents import Tool + from typing import Any, Optional + + class {class_name}(Tool): + name = "{self.name}" + description = {json.dumps(textwrap.dedent(self.description).strip())} + inputs = {repr(self.inputs)} + output_type = "{self.output_type}" + """ + ).strip() + + # Add output_schema if it exists + if hasattr(self, "output_schema") and self.output_schema is not None: + tool_code += f"\n output_schema = {repr(self.output_schema)}" + import re + + def add_self_argument(source_code: str) -> str: + """Add 'self' as first argument to a function definition if not present.""" + pattern = r"def forward\(((?!self)[^)]*)\)" + + def replacement(match): + args = match.group(1).strip() + if args: # If there are other arguments + return f"def forward(self, {args})" + return "def forward(self)" + + return re.sub(pattern, replacement, source_code) + + forward_source_code = forward_source_code.replace(self.name, "forward") + forward_source_code = add_self_argument(forward_source_code) + forward_source_code = forward_source_code.replace("@tool", "").strip() + tool_code += "\n\n" + textwrap.indent(forward_source_code, " ") + + else: # If the tool was not created by the @tool decorator, it was made by subclassing Tool + if type(self).__name__ in [ + "SpaceToolWrapper", + "LangChainToolWrapper", + "GradioToolWrapper", + ]: + raise ValueError( + "Cannot save objects created with from_space, from_langchain or from_gradio, as this would create errors." + ) + + validate_tool_attributes(self.__class__) + + tool_code = "from typing import Any, Optional\n" + instance_to_source(self, base_cls=Tool) + + requirements = {el for el in get_imports(tool_code) if el not in sys.stdlib_module_names} | {"smolagents"} + + tool_dict = {"name": self.name, "code": tool_code, "requirements": sorted(requirements)} + + # Add output_schema if it exists + if hasattr(self, "output_schema") and self.output_schema is not None: + tool_dict["output_schema"] = self.output_schema + + return tool_dict + + @classmethod + def from_dict(cls, tool_dict: dict[str, Any], **kwargs) -> "Tool": + """ + Create tool from a dictionary representation. + + Args: + tool_dict (`dict[str, Any]`): Dictionary representation of the tool. + **kwargs: Additional keyword arguments to pass to the tool's constructor. + + Returns: + `Tool`: Tool object. + """ + if "code" not in tool_dict: + raise ValueError("Tool dictionary must contain 'code' key with the tool source code") + + tool = cls.from_code(tool_dict["code"], **kwargs) + + # Set output_schema if it exists in the dictionary + if "output_schema" in tool_dict: + tool.output_schema = tool_dict["output_schema"] + + return tool + + def save(self, output_dir: str | Path, tool_file_name: str = "tool", make_gradio_app: bool = True): + """ + Saves the relevant code files for your tool so it can be pushed to the Hub. This will copy the code of your + tool in `output_dir` as well as autogenerate: + + - a `{tool_file_name}.py` file containing the logic for your tool. + If you pass `make_gradio_app=True`, this will also write: + - an `app.py` file providing a UI for your tool when it is exported to a Space with `tool.push_to_hub()` + - a `requirements.txt` containing the names of the modules used by your tool (as detected when inspecting its + code) + + Args: + output_dir (`str` or `Path`): The folder in which you want to save your tool. + tool_file_name (`str`, *optional*): The file name in which you want to save your tool. + make_gradio_app (`bool`, *optional*, defaults to True): Whether to also export a `requirements.txt` file and Gradio UI. + """ + # Ensure output directory exists + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + # Save tool file + self._write_file(output_path / f"{tool_file_name}.py", self._get_tool_code()) + if make_gradio_app: + # Save app file + self._write_file(output_path / "app.py", self._get_gradio_app_code(tool_module_name=tool_file_name)) + # Save requirements file + self._write_file(output_path / "requirements.txt", self._get_requirements()) + + def _write_file(self, file_path: Path, content: str) -> None: + """Writes content to a file with UTF-8 encoding.""" + file_path.write_text(content, encoding="utf-8") + + def push_to_hub( + self, + repo_id: str, + commit_message: str = "Upload tool", + private: bool | None = None, + token: bool | str | None = None, + create_pr: bool = False, + ) -> str: + """ + Upload the tool to the Hub. + + Parameters: + repo_id (`str`): + The name of the repository you want to push your tool to. It should contain your organization name when + pushing to a given organization. + commit_message (`str`, *optional*, defaults to `"Upload tool"`): + Message to commit while pushing. + private (`bool`, *optional*): + Whether to make the repo private. If `None` (default), the repo will be public unless the organization's default is private. This value is ignored if the repo already exists. + token (`bool` or `str`, *optional*): + The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated + when running `huggingface-cli login` (stored in `~/.huggingface`). + create_pr (`bool`, *optional*, defaults to `False`): + Whether to create a PR with the uploaded files or directly commit. + """ + # Initialize repository + repo_id = self._initialize_hub_repo(repo_id, token, private) + # Prepare files for commit + additions = self._prepare_hub_files() + # Create commit + return create_commit( + repo_id=repo_id, + operations=additions, + commit_message=commit_message, + token=token, + create_pr=create_pr, + repo_type="space", + ) + + @staticmethod + def _initialize_hub_repo(repo_id: str, token: bool | str | None, private: bool | None) -> str: + """Initialize repository on Hugging Face Hub.""" + repo_url = create_repo( + repo_id=repo_id, + token=token, + private=private, + exist_ok=True, + repo_type="space", + space_sdk="gradio", + ) + metadata_update(repo_url.repo_id, {"tags": ["smolagents", "tool"]}, repo_type="space", token=token) + return repo_url.repo_id + + def _prepare_hub_files(self) -> list: + """Prepare files for Hub commit.""" + additions = [ + # Add tool code + CommitOperationAdd( + path_in_repo="tool.py", + path_or_fileobj=self._get_tool_code().encode(), + ), + # Add Gradio app + CommitOperationAdd( + path_in_repo="app.py", + path_or_fileobj=self._get_gradio_app_code().encode(), + ), + # Add requirements + CommitOperationAdd( + path_in_repo="requirements.txt", + path_or_fileobj=self._get_requirements().encode(), + ), + ] + return additions + + def _get_tool_code(self) -> str: + """Get the tool's code.""" + return self.to_dict()["code"] + + def _get_gradio_app_code(self, tool_module_name: str = "tool") -> str: + """Get the Gradio app code.""" + class_name = self.__class__.__name__ + return textwrap.dedent( + f"""\ + from smolagents import launch_gradio_demo + from {tool_module_name} import {class_name} + + tool = {class_name}() + launch_gradio_demo(tool) + """ + ) + + def _get_requirements(self) -> str: + """Get the requirements.""" + return "\n".join(self.to_dict()["requirements"]) + + @classmethod + def from_hub( + cls, + repo_id: str, + token: str | None = None, + trust_remote_code: bool = False, + **kwargs, + ): + """ + Loads a tool defined on the Hub. + + + + Loading a tool from the Hub means that you'll download the tool and execute it locally. + ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when + installing a package using pip/npm/apt. + + + + Args: + repo_id (`str`): + The name of the Space repo on the Hub where your tool is defined. + token (`str`, *optional*): + The token to identify you on hf.co. If unset, will use the token generated when running + `huggingface-cli login` (stored in `~/.huggingface`). + trust_remote_code(`str`, *optional*, defaults to False): + This flags marks that you understand the risk of running remote code and that you trust this tool. + If not setting this to True, loading the tool from Hub will fail. + kwargs (additional keyword arguments, *optional*): + Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as + `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the + others will be passed along to its init. + """ + if not trust_remote_code: + raise ValueError( + "Loading a tool from Hub requires to acknowledge you trust its code: to do so, pass `trust_remote_code=True`." + ) + + # Get the tool's tool.py file. + tool_file = hf_hub_download( + repo_id, + "tool.py", + token=token, + repo_type="space", + cache_dir=kwargs.pop("cache_dir", None), + force_download=kwargs.pop("force_download", None), + proxies=kwargs.pop("proxies", None), + revision=kwargs.pop("revision", None), + subfolder=kwargs.pop("subfolder", None), + local_files_only=kwargs.pop("local_files_only", None), + ) + + tool_code = Path(tool_file).read_text() + return Tool.from_code(tool_code, **kwargs) + + @classmethod + def from_code(cls, tool_code: str, **kwargs): + module = types.ModuleType("dynamic_tool") + + exec(tool_code, module.__dict__) + + # Find the Tool subclass + tool_class = next( + ( + obj + for _, obj in inspect.getmembers(module, inspect.isclass) + if issubclass(obj, Tool) and obj is not Tool + ), + None, + ) + + if tool_class is None: + raise ValueError("No Tool subclass found in the code.") + + if not isinstance(tool_class.inputs, dict): + tool_class.inputs = ast.literal_eval(tool_class.inputs) + + # Handle output_schema if it exists and is a string representation + if hasattr(tool_class, "output_schema") and isinstance(tool_class.output_schema, str): + tool_class.output_schema = ast.literal_eval(tool_class.output_schema) + + return tool_class(**kwargs) + + @staticmethod + def from_space( + space_id: str, + name: str, + description: str = "", + api_name: str | None = None, + token: str | None = None, + ): + """ + Creates a [`Tool`] from a Space given its id on the Hub. + + Args: + space_id (`str`): + The id of the Space on the Hub. + name (`str`): + The name of the tool. + description (`str`): + The description of the tool. + api_name (`str`, *optional*): + The specific api_name to use, if the space has several tabs. If not precised, will default to the first available api. + token (`str`, *optional*): + Add your token to access private spaces or increase your GPU quotas. + Returns: + [`Tool`]: + The Space, as a tool. + + Examples: + ```py + >>> image_generator = Tool.from_space( + ... space_id="black-forest-labs/FLUX.1-schnell", + ... name="image-generator", + ... description="Generate an image from a prompt" + ... ) + >>> image = image_generator("Generate an image of a cool surfer in Tahiti") + ``` + ```py + >>> face_swapper = Tool.from_space( + ... "tuan2308/face-swap", + ... "face_swapper", + ... "Tool that puts the face shown on the first image on the second image. You can give it paths to images.", + ... ) + >>> image = face_swapper('./aymeric.jpeg', './ruth.jpg') + ``` + """ + from gradio_client import Client, handle_file + + class SpaceToolWrapper(Tool): + skip_forward_signature_validation = True + + def __init__( + self, + space_id: str, + name: str, + description: str = "", + api_name: str | None = None, + token: str | None = None, + ): + self.name = name + self.description = description + self.client = Client(space_id, hf_token=token) + space_api = self.client.view_api(return_format="dict", print_info=False) + assert isinstance(space_api, dict) + space_description = space_api["named_endpoints"] + + # If api_name is not defined, take the first of the available APIs for this space + if api_name is None: + api_name = list(space_description.keys())[0] + warnings.warn( + f"Since `api_name` was not defined, it was automatically set to the first available API: `{api_name}`." + ) + self.api_name = api_name + + try: + space_description_api = space_description[api_name] + except KeyError: + raise KeyError(f"Could not find specified {api_name=} among available api names.") + self.inputs = {} + for parameter in space_description_api["parameters"]: + parameter_type = parameter["type"]["type"] + if parameter_type == "object": + parameter_type = "any" + self.inputs[parameter["parameter_name"]] = { + "type": parameter_type, + "description": parameter["python_type"]["description"], + "nullable": parameter["parameter_has_default"], + } + output_component = space_description_api["returns"][0]["component"] + if output_component == "Image": + self.output_type = "image" + elif output_component == "Audio": + self.output_type = "audio" + else: + self.output_type = "any" + self.is_initialized = True + + def sanitize_argument_for_prediction(self, arg): + from gradio_client.utils import is_http_url_like + from PIL.Image import Image + + if isinstance(arg, Image): + temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + arg.save(temp_file.name) + arg = temp_file.name + if ( + (isinstance(arg, str) and os.path.isfile(arg)) + or (isinstance(arg, Path) and arg.exists() and arg.is_file()) + or is_http_url_like(arg) + ): + arg = handle_file(arg) + return arg + + def forward(self, *args, **kwargs): + # Preprocess args and kwargs: + args = list(args) + for i, arg in enumerate(args): + args[i] = self.sanitize_argument_for_prediction(arg) + for arg_name, arg in kwargs.items(): + kwargs[arg_name] = self.sanitize_argument_for_prediction(arg) + + output = self.client.predict(*args, api_name=self.api_name, **kwargs) + if isinstance(output, tuple) or isinstance(output, list): + if isinstance(output[1], str): + raise ValueError("The space returned this message: " + output[1]) + output = output[ + 0 + ] # Sometime the space also returns the generation seed, in which case the result is at index 0 + IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"] + AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".flac"] + if isinstance(output, str) and any([output.endswith(ext) for ext in IMAGE_EXTENSIONS]): + output = AgentImage(output) + elif isinstance(output, str) and any([output.endswith(ext) for ext in AUDIO_EXTENSIONS]): + output = AgentAudio(output) + return output + + return SpaceToolWrapper( + space_id=space_id, + name=name, + description=description, + api_name=api_name, + token=token, + ) + + @staticmethod + def from_gradio(gradio_tool): + """ + Creates a [`Tool`] from a gradio tool. + """ + import inspect + + class GradioToolWrapper(Tool): + def __init__(self, _gradio_tool): + self.name = _gradio_tool.name + self.description = _gradio_tool.description + self.output_type = "string" + self._gradio_tool = _gradio_tool + func_args = list(inspect.signature(_gradio_tool.run).parameters.items()) + self.inputs = { + key: {"type": CONVERSION_DICT[value.annotation], "description": ""} for key, value in func_args + } + self.forward = self._gradio_tool.run + + return GradioToolWrapper(gradio_tool) + + @staticmethod + def from_langchain(langchain_tool): + """ + Creates a [`Tool`] from a langchain tool. + """ + + class LangChainToolWrapper(Tool): + skip_forward_signature_validation = True + + def __init__(self, _langchain_tool): + self.name = _langchain_tool.name.lower() + self.description = _langchain_tool.description + self.inputs = _langchain_tool.args.copy() + for input_content in self.inputs.values(): + if "title" in input_content: + input_content.pop("title") + input_content["description"] = "" + self.output_type = "string" + self.langchain_tool = _langchain_tool + self.is_initialized = True + + def forward(self, *args, **kwargs): + tool_input = kwargs.copy() + for index, argument in enumerate(args): + if index < len(self.inputs): + input_key = next(iter(self.inputs)) + tool_input[input_key] = argument + return self.langchain_tool.run(tool_input) + + return LangChainToolWrapper(langchain_tool) + + +def launch_gradio_demo(tool: Tool): + """ + Launches a gradio demo for a tool. The corresponding tool class needs to properly implement the class attributes + `inputs` and `output_type`. + + Args: + tool (`Tool`): The tool for which to launch the demo. + """ + try: + import gradio as gr + except ImportError: + raise ImportError("Gradio should be installed in order to launch a gradio demo.") + + TYPE_TO_COMPONENT_CLASS_MAPPING = { + "boolean": gr.Checkbox, + "image": gr.Image, + "audio": gr.Audio, + "string": gr.Textbox, + "integer": gr.Number, + "number": gr.Number, + } + + def tool_forward(*args, **kwargs): + return tool(*args, sanitize_inputs_outputs=True, **kwargs) + + tool_forward.__signature__ = inspect.signature(tool.forward) + + gradio_inputs = [] + for input_name, input_details in tool.inputs.items(): + input_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[input_details["type"]] + new_component = input_gradio_component_class(label=input_name) + gradio_inputs.append(new_component) + + output_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[tool.output_type] + gradio_output = output_gradio_component_class(label="Output") + + gr.Interface( + fn=tool_forward, + inputs=gradio_inputs, + outputs=gradio_output, + title=tool.name, + description=tool.description, + api_name=tool.name, + ).launch() + + +def load_tool( + repo_id, + model_repo_id: str | None = None, + token: str | None = None, + trust_remote_code: bool = False, + **kwargs, +): + """ + Main function to quickly load a tool from the Hub. + + + + Loading a tool means that you'll download the tool and execute it locally. + ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when + installing a package using pip/npm/apt. + + + + Args: + repo_id (`str`): + Space repo ID of a tool on the Hub. + model_repo_id (`str`, *optional*): + Use this argument to use a different model than the default one for the tool you selected. + token (`str`, *optional*): + The token to identify you on hf.co. If unset, will use the token generated when running `huggingface-cli + login` (stored in `~/.huggingface`). + trust_remote_code (`bool`, *optional*, defaults to False): + This needs to be accepted in order to load a tool from Hub. + kwargs (additional keyword arguments, *optional*): + Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as + `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the others + will be passed along to its init. + """ + return Tool.from_hub( + repo_id, + model_repo_id=model_repo_id, + token=token, + trust_remote_code=trust_remote_code, + **kwargs, + ) + + +def add_description(description): + """ + A decorator that adds a description to a function. + """ + + def inner(func): + func.description = description + func.name = func.__name__ + return func + + return inner + + +class ToolCollection: + """ + Tool collections enable loading a collection of tools in the agent's toolbox. + + Collections can be loaded from a collection in the Hub or from an MCP server, see: + - [`ToolCollection.from_hub`] + - [`ToolCollection.from_mcp`] + + For example and usage, see: [`ToolCollection.from_hub`] and [`ToolCollection.from_mcp`] + """ + + def __init__(self, tools: list[Tool]): + self.tools = tools + + @classmethod + def from_hub( + cls, + collection_slug: str, + token: str | None = None, + trust_remote_code: bool = False, + ) -> "ToolCollection": + """Loads a tool collection from the Hub. + + it adds a collection of tools from all Spaces in the collection to the agent's toolbox + + > [!NOTE] + > Only Spaces will be fetched, so you can feel free to add models and datasets to your collection if you'd + > like for this collection to showcase them. + + Args: + collection_slug (str): The collection slug referencing the collection. + token (str, *optional*): The authentication token if the collection is private. + trust_remote_code (bool, *optional*, defaults to False): Whether to trust the remote code. + + Returns: + ToolCollection: A tool collection instance loaded with the tools. + + Example: + ```py + >>> from smolagents import ToolCollection, CodeAgent + + >>> image_tool_collection = ToolCollection.from_hub("huggingface-tools/diffusion-tools-6630bb19a942c2306a2cdb6f") + >>> agent = CodeAgent(tools=[*image_tool_collection.tools], add_base_tools=True) + + >>> agent.run("Please draw me a picture of rivers and lakes.") + ``` + """ + _collection = get_collection(collection_slug, token=token) + _hub_repo_ids = {item.item_id for item in _collection.items if item.item_type == "space"} + + tools = [Tool.from_hub(repo_id, token, trust_remote_code) for repo_id in _hub_repo_ids] + + return cls(tools) + + @classmethod + @contextmanager + def from_mcp( + cls, + server_parameters: "mcp.StdioServerParameters" | dict, + trust_remote_code: bool = False, + structured_output: bool | None = None, + ) -> "ToolCollection": + """Automatically load a tool collection from an MCP server. + + This method supports Stdio, Streamable HTTP, and legacy HTTP+SSE MCP servers. Look at the `server_parameters` + argument for more details on how to connect to each MCP server. + + Note: a separate thread will be spawned to run an asyncio event loop handling + the MCP server. + + Args: + server_parameters (`mcp.StdioServerParameters` or `dict`): + Configuration parameters to connect to the MCP server. This can be: + + - An instance of `mcp.StdioServerParameters` for connecting a Stdio MCP server via standard input/output using a subprocess. + + - A `dict` with at least: + - "url": URL of the server. + - "transport": Transport protocol to use, one of: + - "streamable-http": Streamable HTTP transport (default). + - "sse": Legacy HTTP+SSE transport (deprecated). + trust_remote_code (`bool`, *optional*, defaults to `False`): + Whether to trust the execution of code from tools defined on the MCP server. + This option should only be set to `True` if you trust the MCP server, + and understand the risks associated with running remote code on your local machine. + If set to `False`, loading tools from MCP will fail. + structured_output (`bool`, *optional*, defaults to `False`): + Whether to enable structured output features for MCP tools. If True, enables: + - Support for outputSchema in MCP tools + - Structured content handling (structuredContent from MCP responses) + - JSON parsing fallback for structured data + If False, uses the original simple text-only behavior for backwards compatibility. + + Returns: + ToolCollection: A tool collection instance. + + Example with a Stdio MCP server: + ```py + >>> import os + >>> from smolagents import ToolCollection, CodeAgent, InferenceClientModel + >>> from mcp import StdioServerParameters + + >>> model = InferenceClientModel() + + >>> server_parameters = StdioServerParameters( + >>> command="uvx", + >>> args=["--quiet", "pubmedmcp@0.1.3"], + >>> env={"UV_PYTHON": "3.12", **os.environ}, + >>> ) + + >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + + Example with structured output enabled: + ```py + >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True, structured_output=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + + Example with a Streamable HTTP MCP server: + ```py + >>> with ToolCollection.from_mcp({"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + """ + # Handle future warning for structured_output default value change + if structured_output is None: + warnings.warn( + "Parameter 'structured_output' was not specified. " + "Currently it defaults to False, but in version 1.25, the default will change to True. " + "To suppress this warning, explicitly set structured_output=True (new behavior) or structured_output=False (legacy behavior). " + "See documentation at https://huggingface.co/docs/smolagents/tutorials/tools#structured-output-and-output-schema-support for more details.", + FutureWarning, + stacklevel=2, + ) + structured_output = False + + try: + from mcpadapt.core import MCPAdapt + from mcpadapt.smolagents_adapter import SmolAgentsAdapter + except ImportError: + raise ImportError( + """Please install 'mcp' extra to use ToolCollection.from_mcp: `pip install 'smolagents[mcp]'`.""" + ) + if isinstance(server_parameters, dict): + transport = server_parameters.get("transport") + if transport is None: + transport = "streamable-http" + server_parameters["transport"] = transport + if transport not in {"sse", "streamable-http"}: + raise ValueError( + f"Unsupported transport: {transport}. Supported transports are 'streamable-http' and 'sse'." + ) + if not trust_remote_code: + raise ValueError( + "Loading tools from MCP requires you to acknowledge you trust the MCP server, " + "as it will execute code on your local machine: pass `trust_remote_code=True`." + ) + with MCPAdapt(server_parameters, SmolAgentsAdapter(structured_output=structured_output)) as tools: + yield cls(tools) + + +def tool(tool_function: Callable) -> Tool: + """ + Convert a function into an instance of a dynamically created Tool subclass. + + Args: + tool_function (`Callable`): Function to convert into a Tool subclass. + Should have type hints for each input and a type hint for the output. + Should also have a docstring including the description of the function + and an 'Args:' part where each argument is described. + """ + tool_json_schema = get_json_schema(tool_function)["function"] + if "return" not in tool_json_schema: + if len(tool_json_schema["parameters"]["properties"]) == 0: + tool_json_schema["return"] = {"type": "null"} + else: + raise TypeHintParsingException( + "Tool return type not found: make sure your function has a return type hint!" + ) + + class SimpleTool(Tool): + def __init__(self): + self.is_initialized = True + + # Set the class attributes + SimpleTool.name = tool_json_schema["name"] + SimpleTool.description = tool_json_schema["description"] + SimpleTool.inputs = tool_json_schema["parameters"]["properties"] + SimpleTool.output_type = tool_json_schema["return"]["type"] + + # Set output_schema if it exists in the JSON schema + if "output_schema" in tool_json_schema: + SimpleTool.output_schema = tool_json_schema["output_schema"] + elif "return" in tool_json_schema and "schema" in tool_json_schema["return"]: + SimpleTool.output_schema = tool_json_schema["return"]["schema"] + + @wraps(tool_function) + def wrapped_function(*args, **kwargs): + return tool_function(*args, **kwargs) + + # Bind the copied function to the forward method + SimpleTool.forward = staticmethod(wrapped_function) + + # Get the signature parameters of the tool function + sig = inspect.signature(tool_function) + # - Add "self" as first parameter to tool_function signature + new_sig = sig.replace( + parameters=[inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] + list(sig.parameters.values()) + ) + # - Set the signature of the forward method + SimpleTool.forward.__signature__ = new_sig + + # Create and attach the source code of the dynamically created tool class and forward method + # - Get the source code of tool_function + tool_source = textwrap.dedent(inspect.getsource(tool_function)) + # - Remove the tool decorator and function definition line + lines = tool_source.splitlines() + tree = ast.parse(tool_source) + # - Find function definition + func_node = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)), None) + if not func_node: + raise ValueError( + f"No function definition found in the provided source of {tool_function.__name__}. " + "Ensure the input is a standard function." + ) + # - Extract decorator lines + decorator_lines = "" + if func_node.decorator_list: + tool_decorators = [d for d in func_node.decorator_list if isinstance(d, ast.Name) and d.id == "tool"] + if len(tool_decorators) > 1: + raise ValueError( + f"Multiple @tool decorators found on function '{func_node.name}'. Only one @tool decorator is allowed." + ) + if len(tool_decorators) < len(func_node.decorator_list): + warnings.warn( + f"Function '{func_node.name}' has decorators other than @tool. " + "This may cause issues with serialization in the remote executor. See issue #1626." + ) + decorator_start = tool_decorators[0].end_lineno if tool_decorators else 0 + decorator_end = func_node.decorator_list[-1].end_lineno + decorator_lines = "\n".join(lines[decorator_start:decorator_end]) + # - Extract tool source body + body_start = func_node.body[0].lineno - 1 # AST lineno starts at 1 + tool_source_body = "\n".join(lines[body_start:]) + # - Create the forward method source, including def line and indentation + forward_method_source = f"def forward{new_sig}:\n{tool_source_body}" + # - Create the class source + indent = " " * 4 # for class method + class_source = ( + textwrap.dedent(f""" + class SimpleTool(Tool): + name: str = "{tool_json_schema["name"]}" + description: str = {json.dumps(textwrap.dedent(tool_json_schema["description"]).strip())} + inputs: dict[str, dict[str, str]] = {tool_json_schema["parameters"]["properties"]} + output_type: str = "{tool_json_schema["return"]["type"]}" + + def __init__(self): + self.is_initialized = True + + """) + + textwrap.indent(decorator_lines, indent) + + textwrap.indent(forward_method_source, indent) + ) + # - Store the source code on both class and method for inspection + SimpleTool.__source__ = class_source + SimpleTool.forward.__source__ = forward_method_source + + simple_tool = SimpleTool() + return simple_tool + + +class PipelineTool(Tool): + """ + A [`Tool`] tailored towards Transformer models. On top of the class attributes of the base class [`Tool`], you will + need to specify: + + - **model_class** (`type`) -- The class to use to load the model in this tool. + - **default_checkpoint** (`str`) -- The default checkpoint that should be used when the user doesn't specify one. + - **pre_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the + pre-processor + - **post_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the + post-processor (when different from the pre-processor). + + Args: + model (`str` or [`transformers.PreTrainedModel`], *optional*): + The name of the checkpoint to use for the model, or the instantiated model. If unset, will default to the + value of the class attribute `default_checkpoint`. + pre_processor (`str` or `Any`, *optional*): + The name of the checkpoint to use for the pre-processor, or the instantiated pre-processor (can be a + tokenizer, an image processor, a feature extractor or a processor). Will default to the value of `model` if + unset. + post_processor (`str` or `Any`, *optional*): + The name of the checkpoint to use for the post-processor, or the instantiated pre-processor (can be a + tokenizer, an image processor, a feature extractor or a processor). Will default to the `pre_processor` if + unset. + device (`int`, `str` or `torch.device`, *optional*): + The device on which to execute the model. Will default to any accelerator available (GPU, MPS etc...), the + CPU otherwise. + device_map (`str` or `dict`, *optional*): + If passed along, will be used to instantiate the model. + model_kwargs (`dict`, *optional*): + Any keyword argument to send to the model instantiation. + token (`str`, *optional*): + The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated when + running `huggingface-cli login` (stored in `~/.huggingface`). + hub_kwargs (additional keyword arguments, *optional*): + Any additional keyword argument to send to the methods that will load the data from the Hub. + """ + + pre_processor_class = None + model_class = None + post_processor_class = None + default_checkpoint = None + description = "This is a pipeline tool" + name = "pipeline" + inputs = {"prompt": str} + output_type = str + skip_forward_signature_validation = True + + def __init__( + self, + model=None, + pre_processor=None, + post_processor=None, + device=None, + device_map=None, + model_kwargs=None, + token=None, + **hub_kwargs, + ): + if not _is_package_available("accelerate") or not _is_package_available("torch"): + raise ModuleNotFoundError( + "Please install 'transformers' extra to use a PipelineTool: `pip install 'smolagents[transformers]'`" + ) + + if model is None: + if self.default_checkpoint is None: + raise ValueError("This tool does not implement a default checkpoint, you need to pass one.") + model = self.default_checkpoint + if pre_processor is None: + pre_processor = model + + self.model = model + self.pre_processor = pre_processor + self.post_processor = post_processor + self.device = device + self.device_map = device_map + self.model_kwargs = {} if model_kwargs is None else model_kwargs + if device_map is not None: + self.model_kwargs["device_map"] = device_map + self.hub_kwargs = hub_kwargs + self.hub_kwargs["token"] = token + + super().__init__() + + def setup(self): + """ + Instantiates the `pre_processor`, `model` and `post_processor` if necessary. + """ + if isinstance(self.pre_processor, str): + if self.pre_processor_class is None: + from transformers import AutoProcessor + + self.pre_processor_class = AutoProcessor + self.pre_processor = self.pre_processor_class.from_pretrained(self.pre_processor, **self.hub_kwargs) + + if isinstance(self.model, str): + self.model = self.model_class.from_pretrained(self.model, **self.model_kwargs, **self.hub_kwargs) + + if self.post_processor is None: + self.post_processor = self.pre_processor + elif isinstance(self.post_processor, str): + if self.post_processor_class is None: + from transformers import AutoProcessor + + self.post_processor_class = AutoProcessor + self.post_processor = self.post_processor_class.from_pretrained(self.post_processor, **self.hub_kwargs) + + if self.device is None: + if self.device_map is not None: + self.device = list(self.model.hf_device_map.values())[0] + else: + from accelerate import PartialState + + self.device = PartialState().default_device + + if self.device_map is None: + self.model.to(self.device) + + super().setup() + + def encode(self, raw_inputs): + """ + Uses the `pre_processor` to prepare the inputs for the `model`. + """ + return self.pre_processor(raw_inputs) + + def forward(self, inputs): + """ + Sends the inputs through the `model`. + """ + import torch + + with torch.no_grad(): + return self.model(**inputs) + + def decode(self, outputs): + """ + Uses the `post_processor` to decode the model output. + """ + return self.post_processor(outputs) + + def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): + import torch + from accelerate.utils import send_to_device + + if not self.is_initialized: + self.setup() + + if sanitize_inputs_outputs: + args, kwargs = handle_agent_input_types(*args, **kwargs) + encoded_inputs = self.encode(*args, **kwargs) + + tensor_inputs = {k: v for k, v in encoded_inputs.items() if isinstance(v, torch.Tensor)} + non_tensor_inputs = {k: v for k, v in encoded_inputs.items() if not isinstance(v, torch.Tensor)} + + encoded_inputs = send_to_device(tensor_inputs, self.device) + outputs = self.forward({**encoded_inputs, **non_tensor_inputs}) + outputs = send_to_device(outputs, "cpu") + decoded_outputs = self.decode(outputs) + if sanitize_inputs_outputs: + decoded_outputs = handle_agent_output_types(decoded_outputs, self.output_type) + return decoded_outputs + + +def get_tools_definition_code(tools: dict[str, Tool]) -> str: + tool_codes = [] + for tool in tools.values(): + validate_tool_attributes(tool.__class__, check_imports=False) + tool_code = instance_to_source(tool, base_cls=Tool) + tool_code = tool_code.replace("from smolagents.tools import Tool", "") + tool_code += f"\n\n{tool.name} = {tool.__class__.__name__}()\n" + tool_codes.append(tool_code) + + tool_definition_code = "\n".join([f"import {module}" for module in BASE_BUILTIN_MODULES]) + tool_definition_code += textwrap.dedent( + """ + from typing import Any + + class Tool: + def __call__(self, *args, **kwargs): + return self.forward(*args, **kwargs) + + def forward(self, *args, **kwargs): + pass # to be implemented in child class + """ + ) + tool_definition_code += "\n\n".join(tool_codes) + return tool_definition_code + + +def validate_tool_arguments(tool: Tool, arguments: Any) -> None: + """Validate tool arguments against tool's input schema. + + Checks that all provided arguments match the tool's expected input types and that + all required arguments are present. Supports both dictionary arguments and single + value arguments for tools with one input parameter. + + Args: + tool (`Tool`): Tool whose input schema will be used for validation. + arguments (`Any`): Arguments to validate. Can be a dictionary mapping + argument names to values, or a single value for tools with one input. + + + Raises: + ValueError: If an argument is not in the tool's input schema, if a required + argument is missing, or if the argument value doesn't match the expected type. + TypeError: If an argument has an incorrect type that cannot be converted + (e.g., string instead of number, excluding integer to number conversion). + + Note: + - Supports type coercion from integer to number + - Handles nullable parameters when explicitly marked in the schema + - Accepts "any" type as a wildcard that matches all types + """ + if isinstance(arguments, dict): + for key, value in arguments.items(): + if key not in tool.inputs: + raise ValueError(f"Argument {key} is not in the tool's input schema") + + actual_type = _get_json_schema_type(type(value))["type"] + expected_type = tool.inputs[key]["type"] + expected_type_is_nullable = tool.inputs[key].get("nullable", False) + + # Type is valid if it matches, is "any", or is null for nullable parameters + if ( + (actual_type != expected_type if isinstance(expected_type, str) else actual_type not in expected_type) + and expected_type != "any" + and not (actual_type == "null" and expected_type_is_nullable) + ): + if actual_type == "integer" and expected_type == "number": + continue + raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'") + + for key, schema in tool.inputs.items(): + key_is_nullable = schema.get("nullable", False) + if key not in arguments and not key_is_nullable: + raise ValueError(f"Argument {key} is required") + return None + else: + expected_type = list(tool.inputs.values())[0]["type"] + if _get_json_schema_type(type(arguments))["type"] != expected_type and not expected_type == "any": + raise TypeError(f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'") + + +__all__ = [ + "AUTHORIZED_TYPES", + "Tool", + "tool", + "load_tool", + "launch_gradio_demo", + "ToolCollection", ] diff --git a/tests/test_tools.py b/tests/test_tools.py index 8e31cf6d4c..f82a4daccd 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,1068 +1,1119 @@ -# coding=utf-8 -# Copyright 2024 HuggingFace Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import inspect -import os -import warnings -from textwrap import dedent -from typing import Any, Literal -from unittest.mock import MagicMock, patch - -import mcp -import numpy as np -import PIL.Image -import pytest - -from smolagents.agent_types import _AGENT_TYPE_MAPPING -from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments - -from .utils.markers import require_run_all - - -class ToolTesterMixin: - def test_inputs_output(self): - assert hasattr(self.tool, "inputs") - assert hasattr(self.tool, "output_type") - - inputs = self.tool.inputs - assert isinstance(inputs, dict) - - for _, input_spec in inputs.items(): - assert "type" in input_spec - assert "description" in input_spec - assert input_spec["type"] in AUTHORIZED_TYPES - assert isinstance(input_spec["description"], str) - - output_type = self.tool.output_type - assert output_type in AUTHORIZED_TYPES - - def test_common_attributes(self): - assert hasattr(self.tool, "description") - assert hasattr(self.tool, "name") - assert hasattr(self.tool, "inputs") - assert hasattr(self.tool, "output_type") - - def test_agent_type_output(self, create_inputs): - inputs = create_inputs(self.tool.inputs) - output = self.tool(**inputs, sanitize_inputs_outputs=True) - if self.tool.output_type != "any": - agent_type = _AGENT_TYPE_MAPPING[self.tool.output_type] - assert isinstance(output, agent_type) - - @pytest.fixture - def create_inputs(self, shared_datadir): - def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: - inputs = {} - - for input_name, input_desc in tool_inputs.items(): - input_type = input_desc["type"] - - if input_type == "string": - inputs[input_name] = "Text input" - elif input_type == "image": - inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) - elif input_type == "audio": - inputs[input_name] = np.ones(3000) - else: - raise ValueError(f"Invalid type requested: {input_type}") - - return inputs - - return _create_inputs - - -class TestTool: - @pytest.mark.parametrize( - "type_value, should_raise_error, error_contains", - [ - # Valid cases - ("string", False, None), - (["string", "number"], False, None), - # Invalid cases - ("invalid_type", ValueError, "must be one of"), - (["string", "invalid_type"], ValueError, "must be one of"), - ([123, "string"], TypeError, "when type is a list, all elements must be strings"), - (123, TypeError, "must be a string or list of strings"), - ], - ) - def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): - """Test the validation of the type property in tool inputs.""" - - # Define a tool class with the test type value - def create_tool(): - class TestTool(Tool): - name = "test_tool" - description = "A tool for testing type validation" - inputs = {"text": {"type": type_value, "description": "Some input"}} - output_type = "string" - - def forward(self, text) -> str: - return text - - return TestTool() - - # Check if we expect this to raise an exception - if should_raise_error: - with pytest.raises(should_raise_error) as exc_info: - create_tool() - # Verify the error message contains expected text - assert error_contains in str(exc_info.value) - else: - # Should not raise an exception - tool = create_tool() - assert isinstance(tool, Tool) - - @pytest.mark.parametrize( - "tool_fixture, expected_output", - [ - ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), - ( - "single_input_tool", - 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', - ), - ( - "multi_input_tool", - 'def multi_input_tool(text: string, count: integer) -> object:\n """Tool with multiple inputs\n\n Args:\n text: Text input\n count: Number count\n """', - ), - ( - "multiline_description_tool", - 'def multiline_description_tool(input: string) -> string:\n """This is a tool with\n multiple lines\n in the description\n\n Args:\n input: Some input\n """', - ), - ], - ) - def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): - """Test that to_code_prompt generates properly formatted and indented output.""" - tool = request.getfixturevalue(tool_fixture) - code_prompt = tool.to_code_prompt() - assert code_prompt == expected_output - - @pytest.mark.parametrize( - "tool_fixture, expected_output", - [ - ( - "no_input_tool", - "no_input_tool: Tool with no inputs\n Takes inputs: {}\n Returns an output of type: string", - ), - ( - "single_input_tool", - "single_input_tool: Tool with one input\n Takes inputs: {'text': {'type': 'string', 'description': 'Input text'}}\n Returns an output of type: string", - ), - ( - "multi_input_tool", - "multi_input_tool: Tool with multiple inputs\n Takes inputs: {'text': {'type': 'string', 'description': 'Text input'}, 'count': {'type': 'integer', 'description': 'Number count'}}\n Returns an output of type: object", - ), - ( - "multiline_description_tool", - "multiline_description_tool: This is a tool with\nmultiple lines\nin the description\n Takes inputs: {'input': {'type': 'string', 'description': 'Some input'}}\n Returns an output of type: string", - ), - ], +# coding=utf-8 +# Copyright 2024 HuggingFace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import inspect +import os +import warnings +from textwrap import dedent +from typing import Any, Literal +from unittest.mock import MagicMock, patch + +import mcp +import numpy as np +import PIL.Image +import pytest + +from smolagents.agent_types import _AGENT_TYPE_MAPPING +from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments + +from .utils.markers import require_run_all + + +class ToolTesterMixin: + def test_inputs_output(self): + assert hasattr(self.tool, "inputs") + assert hasattr(self.tool, "output_type") + + inputs = self.tool.inputs + assert isinstance(inputs, dict) + + for _, input_spec in inputs.items(): + assert "type" in input_spec + assert "description" in input_spec + assert input_spec["type"] in AUTHORIZED_TYPES + assert isinstance(input_spec["description"], str) + + output_type = self.tool.output_type + assert output_type in AUTHORIZED_TYPES + + def test_common_attributes(self): + assert hasattr(self.tool, "description") + assert hasattr(self.tool, "name") + assert hasattr(self.tool, "inputs") + assert hasattr(self.tool, "output_type") + + def test_agent_type_output(self, create_inputs): + inputs = create_inputs(self.tool.inputs) + output = self.tool(**inputs, sanitize_inputs_outputs=True) + if self.tool.output_type != "any": + agent_type = _AGENT_TYPE_MAPPING[self.tool.output_type] + assert isinstance(output, agent_type) + + @pytest.fixture + def create_inputs(self, shared_datadir): + def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: + inputs = {} + + for input_name, input_desc in tool_inputs.items(): + input_type = input_desc["type"] + + if input_type == "string": + inputs[input_name] = "Text input" + elif input_type == "image": + inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) + elif input_type == "audio": + inputs[input_name] = np.ones(3000) + else: + raise ValueError(f"Invalid type requested: {input_type}") + + return inputs + + return _create_inputs + + +class TestTool: + @pytest.mark.parametrize( + "type_value, should_raise_error, error_contains", + [ + # Valid cases + ("string", False, None), + (["string", "number"], False, None), + # Invalid cases + ("invalid_type", ValueError, "must be one of"), + (["string", "invalid_type"], ValueError, "must be one of"), + ([123, "string"], TypeError, "when type is a list, all elements must be strings"), + (123, TypeError, "must be a string or list of strings"), + ], + ) + def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): + """Test the validation of the type property in tool inputs.""" + + # Define a tool class with the test type value + def create_tool(): + class TestTool(Tool): + name = "test_tool" + description = "A tool for testing type validation" + inputs = {"text": {"type": type_value, "description": "Some input"}} + output_type = "string" + + def forward(self, text) -> str: + return text + + return TestTool() + + # Check if we expect this to raise an exception + if should_raise_error: + with pytest.raises(should_raise_error) as exc_info: + create_tool() + # Verify the error message contains expected text + assert error_contains in str(exc_info.value) + else: + # Should not raise an exception + tool = create_tool() + assert isinstance(tool, Tool) + + @pytest.mark.parametrize( + "tool_fixture, expected_output", + [ + ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), + ( + "single_input_tool", + 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', + ), + ( + "multi_input_tool", + 'def multi_input_tool(text: string, count: integer) -> object:\n """Tool with multiple inputs\n\n Args:\n text: Text input\n count: Number count\n """', + ), + ( + "multiline_description_tool", + 'def multiline_description_tool(input: string) -> string:\n """This is a tool with\n multiple lines\n in the description\n\n Args:\n input: Some input\n """', + ), + ], + ) + def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): + """Test that to_code_prompt generates properly formatted and indented output.""" + tool = request.getfixturevalue(tool_fixture) + code_prompt = tool.to_code_prompt() + assert code_prompt == expected_output + + @pytest.mark.parametrize( + "tool_fixture, expected_output", + [ + ( + "no_input_tool", + "no_input_tool: Tool with no inputs\n Takes inputs: {}\n Returns an output of type: string", + ), + ( + "single_input_tool", + "single_input_tool: Tool with one input\n Takes inputs: {'text': {'type': 'string', 'description': 'Input text'}}\n Returns an output of type: string", + ), + ( + "multi_input_tool", + "multi_input_tool: Tool with multiple inputs\n Takes inputs: {'text': {'type': 'string', 'description': 'Text input'}, 'count': {'type': 'integer', 'description': 'Number count'}}\n Returns an output of type: object", + ), + ( + "multiline_description_tool", + "multiline_description_tool: This is a tool with\nmultiple lines\nin the description\n Takes inputs: {'input': {'type': 'string', 'description': 'Some input'}}\n Returns an output of type: string", + ), + ], + ) + def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): + """Test that to_tool_calling_prompt generates properly formatted output.""" + tool = request.getfixturevalue(tool_fixture) + tool_calling_prompt = tool.to_tool_calling_prompt() + assert tool_calling_prompt == expected_output + + def test_tool_init_with_decorator(self): + @tool + def coolfunc(a: str, b: int) -> float: + """Cool function + + Args: + a: The first argument + b: The second one + """ + return b + 2, a + + assert coolfunc.output_type == "number" + + def test_tool_init_vanilla(self): + class HFModelDownloadsTool(Tool): + name = "model_download_counter" + description = """ + This is a tool that returns the most downloaded model of a given task on the Hugging Face Hub. + It returns the name of the checkpoint.""" + + inputs = { + "task": { + "type": "string", + "description": "the task category (such as text-classification, depth-estimation, etc)", + } + } + output_type = "string" + + def forward(self, task: str) -> str: + return "best model" + + tool = HFModelDownloadsTool() + assert list(tool.inputs.keys())[0] == "task" + + def test_tool_init_decorator_raises_issues(self): + with pytest.raises(Exception) as e: + + @tool + def coolfunc(a: str, b: int): + """Cool function + + Args: + a: The first argument + b: The second one + """ + return a + b + + assert coolfunc.output_type == "number" + assert "Tool return type not found" in str(e) + + with pytest.raises(Exception) as e: + + @tool + def coolfunc(a: str, b: int) -> int: + """Cool function + + Args: + a: The first argument + """ + return b + a + + assert coolfunc.output_type == "number" + assert "docstring has no description for the argument" in str(e) + + def test_saving_tool_raises_error_imports_outside_function(self, tmp_path): + with pytest.raises(Exception) as e: + import numpy as np + + @tool + def get_current_time() -> str: + """ + Gets the current time. + """ + return str(np.random.random()) + + get_current_time.save(tmp_path) + + assert "np" in str(e) + + # Also test with classic definition + with pytest.raises(Exception) as e: + + class GetCurrentTimeTool(Tool): + name = "get_current_time_tool" + description = "Gets the current time" + inputs = {} + output_type = "string" + + def forward(self): + return str(np.random.random()) + + get_current_time = GetCurrentTimeTool() + get_current_time.save(tmp_path) + + assert "np" in str(e) + + def test_tool_definition_raises_no_error_imports_in_function(self): + @tool + def get_current_time() -> str: + """ + Gets the current time. + """ + from datetime import datetime + + return str(datetime.now()) + + class GetCurrentTimeTool(Tool): + name = "get_current_time_tool" + description = "Gets the current time" + inputs = {} + output_type = "string" + + def forward(self): + from datetime import datetime + + return str(datetime.now()) + + def test_tool_to_dict_allows_no_arg_in_init(self): + """Test that a tool cannot be saved with required args in init""" + + class FailTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def __init__(self, url): + super().__init__(self) + self.url = url + + def forward(self, string_input: str) -> str: + return self.url + string_input + + fail_tool = FailTool("dummy_url") + with pytest.raises(Exception) as e: + fail_tool.to_dict() + assert "Parameters in __init__ must have default values, found required parameters" in str(e) + + class PassTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def __init__(self, url: str | None = "none"): + super().__init__(self) + self.url = url + + def forward(self, string_input: str) -> str: + return self.url + string_input + + fail_tool = PassTool() + fail_tool.to_dict() + + def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): + # Test that using imports from outside functions fails + import numpy as np + + class FailTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def useless_method(self): + self.client = np.random.random() + return "" + + def forward(self, string_input): + return self.useless_method() + string_input + + fail_tool = FailTool() + with pytest.raises(Exception) as e: + fail_tool.save(tmp_path) + assert "'np' is undefined" in str(e) + + # Test that putting these imports inside functions works + class SuccessTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def useless_method(self): + import numpy as np + + self.client = np.random.random() + return "" + + def forward(self, string_input): + return self.useless_method() + string_input + + success_tool = SuccessTool() + success_tool.save(tmp_path) + + def test_tool_missing_class_attributes_raises_error(self): + with pytest.raises(Exception) as e: + + class GetWeatherTool(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + + def forward(self, location: str, celsius: bool | None = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool() + assert "You must set an attribute output_type" in str(e) + + def test_tool_from_decorator_optional_args(self): + @tool + def get_weather(location: str, celsius: bool | None = False) -> str: + """ + Get weather in the next days at given location. + Secretly this tool does not care about the location, it hates the weather everywhere. + + Args: + location: the location + celsius: the temperature type + """ + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + assert "nullable" in get_weather.inputs["celsius"] + assert get_weather.inputs["celsius"]["nullable"] + assert "nullable" not in get_weather.inputs["location"] + + def test_tool_mismatching_nullable_args_raises_error(self): + with pytest.raises(Exception) as e: + + class GetWeatherTool(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + output_type = "string" + + def forward(self, location: str, celsius: bool | None = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool() + assert "Nullable" in str(e) + + with pytest.raises(Exception) as e: + + class GetWeatherTool2(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + output_type = "string" + + def forward(self, location: str, celsius: bool = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool2() + assert "Nullable" in str(e) + + with pytest.raises(Exception) as e: + + class GetWeatherTool3(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + "nullable": True, + }, + } + output_type = "string" + + def forward(self, location, celsius: str) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool3() + assert "Nullable" in str(e) + + def test_tool_default_parameters_is_nullable(self): + @tool + def get_weather(location: str, celsius: bool = False) -> str: + """ + Get weather in the next days at given location. + + Args: + location: The location to get the weather for. + celsius: is the temperature given in celsius? + """ + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + assert get_weather.inputs["celsius"]["nullable"] + + def test_tool_supports_any_none(self, tmp_path): + @tool + def get_weather(location: Any) -> None: + """ + Get weather in the next days at given location. + + Args: + location: The location to get the weather for. + """ + return + + get_weather.save(tmp_path) + assert get_weather.inputs["location"]["type"] == "any" + assert get_weather.output_type == "null" + + def test_tool_supports_array(self): + @tool + def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: + """ + Get weather in the next days at given locations. + + Args: + locations: The locations to get the weather for. + months: The months to get the weather for + """ + return + + assert get_weather.inputs["locations"]["type"] == "array" + assert get_weather.inputs["months"]["type"] == "array" + + def test_tool_supports_string_literal(self): + @tool + def get_weather(unit: Literal["celsius", "fahrenheit"] = "celsius") -> None: + """ + Get weather in the next days at given location. + + Args: + unit: The unit of temperature + """ + return + + assert get_weather.inputs["unit"]["type"] == "string" + assert get_weather.inputs["unit"]["enum"] == ["celsius", "fahrenheit"] + + def test_tool_supports_numeric_literal(self): + @tool + def get_choice(choice: Literal[1, 2, 3]) -> None: + """ + Get choice based on the provided numeric literal. + + Args: + choice: The numeric choice to be made. + """ + return + + assert get_choice.inputs["choice"]["type"] == "integer" + assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] + + def test_tool_supports_nullable_literal(self): + @tool + def get_choice(choice: Literal[1, 2, 3, None]) -> None: + """ + Get choice based on the provided value. + + Args: + choice: The numeric choice to be made. + """ + return + + assert get_choice.inputs["choice"]["type"] == "integer" + assert get_choice.inputs["choice"]["nullable"] is True + assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] + + def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): + @tool + def get_weather(location: Any) -> None: + """ + Get weather in the next days at given location. + And works pretty well. + + Args: + location: The location to get the weather for. + """ + return + + get_weather.save(tmp_path) + with open(os.path.join(tmp_path, "tool.py"), "r", encoding="utf-8") as f: + source_code = f.read() + compile(source_code, f.name, "exec") + + @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) + def test_to_dict_boolean_default_input(self, fixture_name, request): + """Test that boolean input parameter with default value is correctly represented in to_dict output""" + tool = request.getfixturevalue(fixture_name) + result = tool.to_dict() + # Check that the boolean default annotation is preserved + assert "flag: bool = False" in result["code"] + # Check nullable attribute is set for the parameter with default value + assert "'nullable': True" in result["code"] + + @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) + def test_to_dict_optional_input(self, fixture_name, request): + """Test that Optional/nullable input parameter is correctly represented in to_dict output""" + tool = request.getfixturevalue(fixture_name) + result = tool.to_dict() + # Check the Optional type annotation is preserved + assert "optional_text: str | None = None" in result["code"] + # Check that the input is marked as nullable in the code + assert "'nullable': True" in result["code"] + + def test_from_dict_roundtrip(self, example_tool): + # Convert to dict + tool_dict = example_tool.to_dict() + # Create from dict + recreated_tool = Tool.from_dict(tool_dict) + # Verify properties + assert recreated_tool.name == example_tool.name + assert recreated_tool.description == example_tool.description + assert recreated_tool.inputs == example_tool.inputs + assert recreated_tool.output_type == example_tool.output_type + # Verify functionality + test_input = "Hello, world!" + assert recreated_tool(test_input) == test_input.upper() + + def test_tool_from_dict_invalid(self): + # Missing code key + with pytest.raises(ValueError) as e: + Tool.from_dict({"name": "invalid_tool"}) + assert "must contain 'code' key" in str(e) + + def test_tool_decorator_preserves_original_function(self): + # Define a test function with type hints and docstring + def test_function(items: list[str]) -> str: + """Join a list of strings. + Args: + items: A list of strings to join + Returns: + The joined string + """ + return ", ".join(items) + + # Store original function signature, name, and source + original_signature = inspect.signature(test_function) + original_name = test_function.__name__ + original_docstring = test_function.__doc__ + + # Create a tool from the function + test_tool = tool(test_function) + + # Check that the original function is unchanged + assert original_signature == inspect.signature(test_function) + assert original_name == test_function.__name__ + assert original_docstring == test_function.__doc__ + + # Verify that the tool's forward method has a different signature (it has 'self') + tool_forward_sig = inspect.signature(test_tool.forward) + assert list(tool_forward_sig.parameters.keys())[0] == "self" + + # Original function should not have 'self' parameter + assert "self" not in original_signature.parameters + + def test_tool_with_union_type_return(self): + @tool + def union_type_return_tool_function(param: int) -> str | bool: + """ + Tool with output union type. + + Args: + param: Input parameter. + """ + return str(param) if param > 0 else False + + assert isinstance(union_type_return_tool_function, Tool) + assert union_type_return_tool_function.output_type == "any" + + +class TestToolDecorator: + def test_tool_decorator_source_extraction_with_multiple_decorators(self): + """Test that @tool correctly extracts source code with multiple decorators.""" + + def dummy_decorator(func): + return func + + with pytest.warns(UserWarning, match="has decorators other than @tool"): + + @tool + @dummy_decorator + def multi_decorator_tool(text: str) -> str: + """Tool with multiple decorators. + + Args: + text: Input text + """ + return text.upper() + + # Verify the tool works + assert isinstance(multi_decorator_tool, Tool) + assert multi_decorator_tool.name == "multi_decorator_tool" + assert multi_decorator_tool("hello") == "HELLO" + + # Verify the source code extraction is correct + forward_source = multi_decorator_tool.forward.__source__ + assert "def forward(self, text: str) -> str:" in forward_source + assert "return text.upper()" in forward_source + # Should not contain decorator lines + assert "@tool" not in forward_source + assert "@dummy_decorator" not in forward_source + # Should not contain definition line + assert "def multi_decorator_tool" not in forward_source + + def test_tool_decorator_source_extraction_with_multiline_signature(self): + """Test that @tool correctly extracts source code with multiline function signatures.""" + + with warnings.catch_warnings(): + warnings.simplefilter("error") + + @tool + def multiline_signature_tool( + text: str, + count: int = 1, + uppercase: bool = False, + multiline_parameter_1: int = 1_000, + multiline_parameter_2: int = 2_000, + ) -> str: + """Tool with multiline signature. + + Args: + text: Input text + count: Number of repetitions + uppercase: Whether to convert to uppercase + multiline_parameter_1: Dummy parameter + multiline_parameter_2: Dummy parameter + """ + result = text * count + return result.upper() if uppercase else result + + # Verify the tool works + assert isinstance(multiline_signature_tool, Tool) + assert multiline_signature_tool.name == "multiline_signature_tool" + assert multiline_signature_tool("hello", 2, True) == "HELLOHELLO" + + # Verify the source code extraction is correct + forward_source = multiline_signature_tool.forward.__source__ + assert ( + "def forward(self, text: str, count: int=1, uppercase: bool=False, multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" + in forward_source + or "def forward(self, text: str, count: int = 1, uppercase: bool = False, multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" + in forward_source + ) + assert "result = text * count" in forward_source + assert "return result.upper() if uppercase else result" in forward_source + # Should not contain the original multiline function definition + assert "def multiline_signature_tool(" not in forward_source + # Should not contain leftover lines from the original multiline function definition + assert " count: int = 1," not in forward_source + assert " count: int=1," not in forward_source + + def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): + """Test that @tool works with both multiple decorators and multiline signatures.""" + + def dummy_decorator_1(func): + return func + + def dummy_decorator_2(func): + return func + + with pytest.warns(UserWarning, match="has decorators other than @tool"): + + @tool + @dummy_decorator_1 + @dummy_decorator_2 + def complex_tool( + text: str, + multiplier: int = 2, + separator: str = " ", + multiline_parameter_1: int = 1_000, + multiline_parameter_2: int = 2_000, + ) -> str: + """Complex tool with multiple decorators and multiline signature. + + Args: + text: Input text + multiplier: How many times to repeat + separator: What to use between repetitions + multiline_parameter_1: Dummy parameter + multiline_parameter_2: Dummy parameter + """ + parts = [text] * multiplier + return separator.join(parts) + + # Verify the tool works + assert isinstance(complex_tool, Tool) + assert complex_tool.name == "complex_tool" + assert complex_tool("hello", 3, "-") == "hello-hello-hello" + + # Verify the source code extraction is correct + forward_source = complex_tool.forward.__source__ + assert ( + "def forward(self, text: str, multiplier: int=2, separator: str=' ', multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" + in forward_source + or "def forward(self, text: str, multiplier: int = 2, separator: str = ' ', multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" + in forward_source + ) + assert "parts = [text] * multiplier" in forward_source + assert "return separator.join(parts)" in forward_source + # Should not contain any decorator lines + assert "@tool" not in forward_source + assert "@dummy_decorator_1" not in forward_source + assert "@dummy_decorator_2" not in forward_source + # Should not contain leftover lines from the original multiline function definition + assert " multiplier: int = 2," not in forward_source + assert " multiplier: int=2," not in forward_source + + +@pytest.fixture +def mock_server_parameters(): + return MagicMock() + + +@pytest.fixture +def mock_mcp_adapt(): + with patch("mcpadapt.core.MCPAdapt") as mock: + mock.return_value.__enter__.return_value = ["tool1", "tool2"] + mock.return_value.__exit__.return_value = None + yield mock + + +@pytest.fixture +def mock_smolagents_adapter(): + with patch("mcpadapt.smolagents_adapter.SmolAgentsAdapter") as mock: + yield mock + + +# Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior +@pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") +class TestToolCollection: + def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): + with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: + assert isinstance(tool_collection, ToolCollection) + assert len(tool_collection.tools) == 2 + assert "tool1" in tool_collection.tools + assert "tool2" in tool_collection.tools + + @require_run_all + def test_integration_from_mcp(self): + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server") + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run() + """).strip() + + mcp_server_params = mcp.StdioServerParameters( + command="python", + args=["-c", mcp_server_script], + ) + + with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + + def test_integration_from_mcp_with_streamable_http(self): + import subprocess + import time + + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run(transport="streamable-http") + """).strip() + + # start the SSE mcp server in a subprocess + server_process = subprocess.Popen( + ["python", "-c", mcp_server_script], + ) + + # wait for the server to start + time.sleep(1) + + try: + with ToolCollection.from_mcp( + {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True + ) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + finally: + # clean up the process when test is done + server_process.kill() + server_process.wait() + + def test_integration_from_mcp_with_sse(self): + import subprocess + import time + + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run("sse") + """).strip() + + # start the SSE mcp server in a subprocess + server_process = subprocess.Popen( + ["python", "-c", mcp_server_script], + ) + + # wait for the server to start + time.sleep(1) + + try: + with ToolCollection.from_mcp( + {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True + ) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + finally: + # clean up the process when test is done + server_process.kill() + server_process.wait() + + +@pytest.mark.parametrize("tool_fixture_name", ["boolean_default_tool_class"]) +def test_launch_gradio_demo_does_not_raise(tool_fixture_name, request): + tool = request.getfixturevalue(tool_fixture_name) + with patch("gradio.Interface.launch") as mock_launch: + launch_gradio_demo(tool) + assert mock_launch.call_count == 1 + + +@pytest.mark.parametrize( + "tool_input_type, expected_input, expects_error", + [ + (bool, True, False), + (str, "b", False), + (int, 1, False), + (float, 1, False), + (list, ["a", "b"], False), + (list[str], ["a", "b"], False), + (dict[str, str], {"a": "b"}, False), + (dict[str, str], "b", True), + (bool, "b", True), + (str | int, "a", False), + (str | int, 1, False), + (str | int, None, True), + (str | int, True, True), + ], +) +def test_validate_tool_arguments(tool_input_type, expected_input, expects_error): + @tool + def test_tool(argument_a: tool_input_type) -> str: + """Fake tool + + Args: + argument_a: The input + """ + return argument_a + + if expects_error: + with pytest.raises((ValueError, TypeError)): + validate_tool_arguments(test_tool, {"argument_a": expected_input}) + + else: + # Should not raise any exception + validate_tool_arguments(test_tool, {"argument_a": expected_input}) + + +@pytest.mark.parametrize( + "scenario, type_hint, default, input_value, expected_error_message", + [ + # Required parameters (no default) + # - Valid input + ("required_unsupported_none", str, ..., "text", None), + # - None not allowed + ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), + # - Missing required parameter is not allowed + ("required_unsupported_none", str, ..., ..., "Argument param is required"), + # + # Required parameters but supports None + # - Valid input + ("required_supported_none", str | None, ..., "text", None), + # - None allowed + ("required_supported_none", str | None, ..., None, None), + # - Missing required parameter is not allowed + # TODO: Fix this test case: property is marked as nullable because it can be None, but it can't be missing because it is required + # ("required_supported_none", str | None, ..., ..., "Argument param is required"), + pytest.param( + "required_supported_none", + str | None, + ..., + ..., + "Argument param is required", + marks=pytest.mark.skip(reason="TODO: Fix this test case"), + ), + # + # Optional parameters (has default, doesn't support None) + # - Valid input + ("optional_unsupported_none", str, "default", "text", None), + # - None not allowed + # TODO: Fix this test case: property is marked as nullable because it has a default value, but it can't be None + # ("optional_unsupported_none", str, "default", None, "Argument param has type 'null' but should be 'string'"), + pytest.param( + "optional_unsupported_none", + str, + "default", + None, + "Argument param has type 'null' but should be 'string'", + marks=pytest.mark.skip(reason="TODO: Fix this test case"), + ), + # - Missing optional parameter is allowed + ("optional_unsupported_none", str, "default", ..., None), + # + # Optional and supports None parameters with string default + # - Valid input + ("optional_supported_none_str_default", str | None, "default", "text", None), + # - None allowed + ("optional_supported_none_str_default", str | None, "default", None, None), + # - Missing optional parameter is allowed + ("optional_supported_none_str_default", str | None, "default", ..., None), + # + # Optional and supports None parameters with None default + # - Valid input + ("optional_supported_none_none_default", str | None, None, "text", None), + # - None allowed + ("optional_supported_none_none_default", str | None, None, None, None), + # - Missing optional parameter is allowed + ("optional_supported_none_none_default", str | None, None, ..., None), + ], +) +def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): + """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" + + # Create a tool with the appropriate signature + if default is ...: # Using Ellipsis to indicate no default value + + @tool + def test_tool(param: type_hint) -> str: + """Test tool. + + Args: + param: Input param + """ + return str(param) if param is not None else "NULL" + else: + + @tool + def test_tool(param: type_hint = default) -> str: + """Test tool. + + Args: + param: Input param. + """ + return str(param) if param is not None else "NULL" + + # Test with the input dictionary + input_dict = {"param": input_value} if input_value is not ... else {} + + if expected_error_message: + with pytest.raises((ValueError, TypeError), match=expected_error_message): + validate_tool_arguments(test_tool, input_dict) + else: + # Should not raise any exception + validate_tool_arguments(test_tool, input_dict) + +def test_from_hub_pops_hub_download_options(tmp_path): + """Regression for #2928: Hub download options must not reach the tool constructor. + + ``Tool.from_hub`` documents that extra kwargs are split between Hub download + options and tool-constructor options. Loading a tool with a no-argument + constructor must not fail because ``cache_dir`` leaked into ``__init__``. + """ + tool_source = dedent( + ''' + from smolagents import Tool + + + class EchoTool(Tool): + name = "echo" + description = "Return an integer unchanged." + inputs = {"value": {"type": "integer", "description": "Input integer."}} + output_type = "integer" + + def __init__(self, suffix: str | None = None): + super().__init__() + self.suffix = suffix + + def forward(self, value: int) -> int: + return value + ''' ) - def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): - """Test that to_tool_calling_prompt generates properly formatted output.""" - tool = request.getfixturevalue(tool_fixture) - tool_calling_prompt = tool.to_tool_calling_prompt() - assert tool_calling_prompt == expected_output - - def test_tool_init_with_decorator(self): - @tool - def coolfunc(a: str, b: int) -> float: - """Cool function - - Args: - a: The first argument - b: The second one - """ - return b + 2, a - - assert coolfunc.output_type == "number" - - def test_tool_init_vanilla(self): - class HFModelDownloadsTool(Tool): - name = "model_download_counter" - description = """ - This is a tool that returns the most downloaded model of a given task on the Hugging Face Hub. - It returns the name of the checkpoint.""" - - inputs = { - "task": { - "type": "string", - "description": "the task category (such as text-classification, depth-estimation, etc)", - } - } - output_type = "string" - - def forward(self, task: str) -> str: - return "best model" - - tool = HFModelDownloadsTool() - assert list(tool.inputs.keys())[0] == "task" - - def test_tool_init_decorator_raises_issues(self): - with pytest.raises(Exception) as e: - - @tool - def coolfunc(a: str, b: int): - """Cool function - - Args: - a: The first argument - b: The second one - """ - return a + b - - assert coolfunc.output_type == "number" - assert "Tool return type not found" in str(e) - - with pytest.raises(Exception) as e: - - @tool - def coolfunc(a: str, b: int) -> int: - """Cool function - - Args: - a: The first argument - """ - return b + a - - assert coolfunc.output_type == "number" - assert "docstring has no description for the argument" in str(e) - - def test_saving_tool_raises_error_imports_outside_function(self, tmp_path): - with pytest.raises(Exception) as e: - import numpy as np - - @tool - def get_current_time() -> str: - """ - Gets the current time. - """ - return str(np.random.random()) - - get_current_time.save(tmp_path) - - assert "np" in str(e) - - # Also test with classic definition - with pytest.raises(Exception) as e: - - class GetCurrentTimeTool(Tool): - name = "get_current_time_tool" - description = "Gets the current time" - inputs = {} - output_type = "string" - - def forward(self): - return str(np.random.random()) - - get_current_time = GetCurrentTimeTool() - get_current_time.save(tmp_path) - - assert "np" in str(e) - - def test_tool_definition_raises_no_error_imports_in_function(self): - @tool - def get_current_time() -> str: - """ - Gets the current time. - """ - from datetime import datetime - - return str(datetime.now()) - - class GetCurrentTimeTool(Tool): - name = "get_current_time_tool" - description = "Gets the current time" - inputs = {} - output_type = "string" - - def forward(self): - from datetime import datetime - - return str(datetime.now()) - - def test_tool_to_dict_allows_no_arg_in_init(self): - """Test that a tool cannot be saved with required args in init""" - - class FailTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def __init__(self, url): - super().__init__(self) - self.url = url - - def forward(self, string_input: str) -> str: - return self.url + string_input - - fail_tool = FailTool("dummy_url") - with pytest.raises(Exception) as e: - fail_tool.to_dict() - assert "Parameters in __init__ must have default values, found required parameters" in str(e) - - class PassTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def __init__(self, url: str | None = "none"): - super().__init__(self) - self.url = url - - def forward(self, string_input: str) -> str: - return self.url + string_input - - fail_tool = PassTool() - fail_tool.to_dict() - - def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): - # Test that using imports from outside functions fails - import numpy as np - - class FailTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def useless_method(self): - self.client = np.random.random() - return "" - - def forward(self, string_input): - return self.useless_method() + string_input - - fail_tool = FailTool() - with pytest.raises(Exception) as e: - fail_tool.save(tmp_path) - assert "'np' is undefined" in str(e) - - # Test that putting these imports inside functions works - class SuccessTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def useless_method(self): - import numpy as np - - self.client = np.random.random() - return "" - - def forward(self, string_input): - return self.useless_method() + string_input - - success_tool = SuccessTool() - success_tool.save(tmp_path) - - def test_tool_missing_class_attributes_raises_error(self): - with pytest.raises(Exception) as e: - - class GetWeatherTool(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - - def forward(self, location: str, celsius: bool | None = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool() - assert "You must set an attribute output_type" in str(e) - - def test_tool_from_decorator_optional_args(self): - @tool - def get_weather(location: str, celsius: bool | None = False) -> str: - """ - Get weather in the next days at given location. - Secretly this tool does not care about the location, it hates the weather everywhere. - - Args: - location: the location - celsius: the temperature type - """ - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - assert "nullable" in get_weather.inputs["celsius"] - assert get_weather.inputs["celsius"]["nullable"] - assert "nullable" not in get_weather.inputs["location"] - - def test_tool_mismatching_nullable_args_raises_error(self): - with pytest.raises(Exception) as e: - - class GetWeatherTool(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - output_type = "string" - - def forward(self, location: str, celsius: bool | None = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool() - assert "Nullable" in str(e) - - with pytest.raises(Exception) as e: - - class GetWeatherTool2(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - output_type = "string" - - def forward(self, location: str, celsius: bool = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool2() - assert "Nullable" in str(e) - - with pytest.raises(Exception) as e: - - class GetWeatherTool3(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - "nullable": True, - }, - } - output_type = "string" - - def forward(self, location, celsius: str) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool3() - assert "Nullable" in str(e) - - def test_tool_default_parameters_is_nullable(self): - @tool - def get_weather(location: str, celsius: bool = False) -> str: - """ - Get weather in the next days at given location. - - Args: - location: The location to get the weather for. - celsius: is the temperature given in celsius? - """ - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - assert get_weather.inputs["celsius"]["nullable"] - - def test_tool_supports_any_none(self, tmp_path): - @tool - def get_weather(location: Any) -> None: - """ - Get weather in the next days at given location. - - Args: - location: The location to get the weather for. - """ - return - - get_weather.save(tmp_path) - assert get_weather.inputs["location"]["type"] == "any" - assert get_weather.output_type == "null" - - def test_tool_supports_array(self): - @tool - def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: - """ - Get weather in the next days at given locations. - - Args: - locations: The locations to get the weather for. - months: The months to get the weather for - """ - return - - assert get_weather.inputs["locations"]["type"] == "array" - assert get_weather.inputs["months"]["type"] == "array" - - def test_tool_supports_string_literal(self): - @tool - def get_weather(unit: Literal["celsius", "fahrenheit"] = "celsius") -> None: - """ - Get weather in the next days at given location. - - Args: - unit: The unit of temperature - """ - return - - assert get_weather.inputs["unit"]["type"] == "string" - assert get_weather.inputs["unit"]["enum"] == ["celsius", "fahrenheit"] - - def test_tool_supports_numeric_literal(self): - @tool - def get_choice(choice: Literal[1, 2, 3]) -> None: - """ - Get choice based on the provided numeric literal. - - Args: - choice: The numeric choice to be made. - """ - return - - assert get_choice.inputs["choice"]["type"] == "integer" - assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - - def test_tool_supports_nullable_literal(self): - @tool - def get_choice(choice: Literal[1, 2, 3, None]) -> None: - """ - Get choice based on the provided value. - - Args: - choice: The numeric choice to be made. - """ - return - - assert get_choice.inputs["choice"]["type"] == "integer" - assert get_choice.inputs["choice"]["nullable"] is True - assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - - def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): - @tool - def get_weather(location: Any) -> None: - """ - Get weather in the next days at given location. - And works pretty well. - - Args: - location: The location to get the weather for. - """ - return - - get_weather.save(tmp_path) - with open(os.path.join(tmp_path, "tool.py"), "r", encoding="utf-8") as f: - source_code = f.read() - compile(source_code, f.name, "exec") - - @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) - def test_to_dict_boolean_default_input(self, fixture_name, request): - """Test that boolean input parameter with default value is correctly represented in to_dict output""" - tool = request.getfixturevalue(fixture_name) - result = tool.to_dict() - # Check that the boolean default annotation is preserved - assert "flag: bool = False" in result["code"] - # Check nullable attribute is set for the parameter with default value - assert "'nullable': True" in result["code"] - - @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) - def test_to_dict_optional_input(self, fixture_name, request): - """Test that Optional/nullable input parameter is correctly represented in to_dict output""" - tool = request.getfixturevalue(fixture_name) - result = tool.to_dict() - # Check the Optional type annotation is preserved - assert "optional_text: str | None = None" in result["code"] - # Check that the input is marked as nullable in the code - assert "'nullable': True" in result["code"] - - def test_from_dict_roundtrip(self, example_tool): - # Convert to dict - tool_dict = example_tool.to_dict() - # Create from dict - recreated_tool = Tool.from_dict(tool_dict) - # Verify properties - assert recreated_tool.name == example_tool.name - assert recreated_tool.description == example_tool.description - assert recreated_tool.inputs == example_tool.inputs - assert recreated_tool.output_type == example_tool.output_type - # Verify functionality - test_input = "Hello, world!" - assert recreated_tool(test_input) == test_input.upper() - - def test_tool_from_dict_invalid(self): - # Missing code key - with pytest.raises(ValueError) as e: - Tool.from_dict({"name": "invalid_tool"}) - assert "must contain 'code' key" in str(e) - - def test_tool_decorator_preserves_original_function(self): - # Define a test function with type hints and docstring - def test_function(items: list[str]) -> str: - """Join a list of strings. - Args: - items: A list of strings to join - Returns: - The joined string - """ - return ", ".join(items) - - # Store original function signature, name, and source - original_signature = inspect.signature(test_function) - original_name = test_function.__name__ - original_docstring = test_function.__doc__ - - # Create a tool from the function - test_tool = tool(test_function) - - # Check that the original function is unchanged - assert original_signature == inspect.signature(test_function) - assert original_name == test_function.__name__ - assert original_docstring == test_function.__doc__ - - # Verify that the tool's forward method has a different signature (it has 'self') - tool_forward_sig = inspect.signature(test_tool.forward) - assert list(tool_forward_sig.parameters.keys())[0] == "self" - - # Original function should not have 'self' parameter - assert "self" not in original_signature.parameters - - def test_tool_with_union_type_return(self): - @tool - def union_type_return_tool_function(param: int) -> str | bool: - """ - Tool with output union type. - - Args: - param: Input parameter. - """ - return str(param) if param > 0 else False - - assert isinstance(union_type_return_tool_function, Tool) - assert union_type_return_tool_function.output_type == "any" - - -class TestToolDecorator: - def test_tool_decorator_source_extraction_with_multiple_decorators(self): - """Test that @tool correctly extracts source code with multiple decorators.""" - - def dummy_decorator(func): - return func - - with pytest.warns(UserWarning, match="has decorators other than @tool"): - - @tool - @dummy_decorator - def multi_decorator_tool(text: str) -> str: - """Tool with multiple decorators. - - Args: - text: Input text - """ - return text.upper() - - # Verify the tool works - assert isinstance(multi_decorator_tool, Tool) - assert multi_decorator_tool.name == "multi_decorator_tool" - assert multi_decorator_tool("hello") == "HELLO" - - # Verify the source code extraction is correct - forward_source = multi_decorator_tool.forward.__source__ - assert "def forward(self, text: str) -> str:" in forward_source - assert "return text.upper()" in forward_source - # Should not contain decorator lines - assert "@tool" not in forward_source - assert "@dummy_decorator" not in forward_source - # Should not contain definition line - assert "def multi_decorator_tool" not in forward_source - - def test_tool_decorator_source_extraction_with_multiline_signature(self): - """Test that @tool correctly extracts source code with multiline function signatures.""" - - with warnings.catch_warnings(): - warnings.simplefilter("error") - - @tool - def multiline_signature_tool( - text: str, - count: int = 1, - uppercase: bool = False, - multiline_parameter_1: int = 1_000, - multiline_parameter_2: int = 2_000, - ) -> str: - """Tool with multiline signature. - - Args: - text: Input text - count: Number of repetitions - uppercase: Whether to convert to uppercase - multiline_parameter_1: Dummy parameter - multiline_parameter_2: Dummy parameter - """ - result = text * count - return result.upper() if uppercase else result - - # Verify the tool works - assert isinstance(multiline_signature_tool, Tool) - assert multiline_signature_tool.name == "multiline_signature_tool" - assert multiline_signature_tool("hello", 2, True) == "HELLOHELLO" - - # Verify the source code extraction is correct - forward_source = multiline_signature_tool.forward.__source__ - assert ( - "def forward(self, text: str, count: int=1, uppercase: bool=False, multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" - in forward_source - or "def forward(self, text: str, count: int = 1, uppercase: bool = False, multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" - in forward_source - ) - assert "result = text * count" in forward_source - assert "return result.upper() if uppercase else result" in forward_source - # Should not contain the original multiline function definition - assert "def multiline_signature_tool(" not in forward_source - # Should not contain leftover lines from the original multiline function definition - assert " count: int = 1," not in forward_source - assert " count: int=1," not in forward_source - - def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): - """Test that @tool works with both multiple decorators and multiline signatures.""" - - def dummy_decorator_1(func): - return func - - def dummy_decorator_2(func): - return func - - with pytest.warns(UserWarning, match="has decorators other than @tool"): - - @tool - @dummy_decorator_1 - @dummy_decorator_2 - def complex_tool( - text: str, - multiplier: int = 2, - separator: str = " ", - multiline_parameter_1: int = 1_000, - multiline_parameter_2: int = 2_000, - ) -> str: - """Complex tool with multiple decorators and multiline signature. - - Args: - text: Input text - multiplier: How many times to repeat - separator: What to use between repetitions - multiline_parameter_1: Dummy parameter - multiline_parameter_2: Dummy parameter - """ - parts = [text] * multiplier - return separator.join(parts) - - # Verify the tool works - assert isinstance(complex_tool, Tool) - assert complex_tool.name == "complex_tool" - assert complex_tool("hello", 3, "-") == "hello-hello-hello" - - # Verify the source code extraction is correct - forward_source = complex_tool.forward.__source__ - assert ( - "def forward(self, text: str, multiplier: int=2, separator: str=' ', multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" - in forward_source - or "def forward(self, text: str, multiplier: int = 2, separator: str = ' ', multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" - in forward_source + tool_path = tmp_path / "tool.py" + tool_path.write_text(tool_source) + + with patch( + "smolagents.tools.hf_hub_download", return_value=str(tool_path) + ) as mock_download: + restored = Tool.from_hub( + "some-user/some-tool", + trust_remote_code=True, + cache_dir="/tmp/cache", + local_files_only=True, + revision="main", + suffix="suffix-value", ) - assert "parts = [text] * multiplier" in forward_source - assert "return separator.join(parts)" in forward_source - # Should not contain any decorator lines - assert "@tool" not in forward_source - assert "@dummy_decorator_1" not in forward_source - assert "@dummy_decorator_2" not in forward_source - # Should not contain leftover lines from the original multiline function definition - assert " multiplier: int = 2," not in forward_source - assert " multiplier: int=2," not in forward_source - - -@pytest.fixture -def mock_server_parameters(): - return MagicMock() - - -@pytest.fixture -def mock_mcp_adapt(): - with patch("mcpadapt.core.MCPAdapt") as mock: - mock.return_value.__enter__.return_value = ["tool1", "tool2"] - mock.return_value.__exit__.return_value = None - yield mock - - -@pytest.fixture -def mock_smolagents_adapter(): - with patch("mcpadapt.smolagents_adapter.SmolAgentsAdapter") as mock: - yield mock - - -# Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior -@pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") -class TestToolCollection: - def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): - with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: - assert isinstance(tool_collection, ToolCollection) - assert len(tool_collection.tools) == 2 - assert "tool1" in tool_collection.tools - assert "tool2" in tool_collection.tools - - @require_run_all - def test_integration_from_mcp(self): - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server") - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run() - """).strip() - - mcp_server_params = mcp.StdioServerParameters( - command="python", - args=["-c", mcp_server_script], - ) - - with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - - def test_integration_from_mcp_with_streamable_http(self): - import subprocess - import time - - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run(transport="streamable-http") - """).strip() - - # start the SSE mcp server in a subprocess - server_process = subprocess.Popen( - ["python", "-c", mcp_server_script], - ) - - # wait for the server to start - time.sleep(1) - - try: - with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True - ) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - finally: - # clean up the process when test is done - server_process.kill() - server_process.wait() - - def test_integration_from_mcp_with_sse(self): - import subprocess - import time - - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run("sse") - """).strip() - - # start the SSE mcp server in a subprocess - server_process = subprocess.Popen( - ["python", "-c", mcp_server_script], - ) - - # wait for the server to start - time.sleep(1) - - try: - with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True - ) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - finally: - # clean up the process when test is done - server_process.kill() - server_process.wait() - - -@pytest.mark.parametrize("tool_fixture_name", ["boolean_default_tool_class"]) -def test_launch_gradio_demo_does_not_raise(tool_fixture_name, request): - tool = request.getfixturevalue(tool_fixture_name) - with patch("gradio.Interface.launch") as mock_launch: - launch_gradio_demo(tool) - assert mock_launch.call_count == 1 - - -@pytest.mark.parametrize( - "tool_input_type, expected_input, expects_error", - [ - (bool, True, False), - (str, "b", False), - (int, 1, False), - (float, 1, False), - (list, ["a", "b"], False), - (list[str], ["a", "b"], False), - (dict[str, str], {"a": "b"}, False), - (dict[str, str], "b", True), - (bool, "b", True), - (str | int, "a", False), - (str | int, 1, False), - (str | int, None, True), - (str | int, True, True), - ], -) -def test_validate_tool_arguments(tool_input_type, expected_input, expects_error): - @tool - def test_tool(argument_a: tool_input_type) -> str: - """Fake tool - - Args: - argument_a: The input - """ - return argument_a - - if expects_error: - with pytest.raises((ValueError, TypeError)): - validate_tool_arguments(test_tool, {"argument_a": expected_input}) - - else: - # Should not raise any exception - validate_tool_arguments(test_tool, {"argument_a": expected_input}) - - -@pytest.mark.parametrize( - "scenario, type_hint, default, input_value, expected_error_message", - [ - # Required parameters (no default) - # - Valid input - ("required_unsupported_none", str, ..., "text", None), - # - None not allowed - ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), - # - Missing required parameter is not allowed - ("required_unsupported_none", str, ..., ..., "Argument param is required"), - # - # Required parameters but supports None - # - Valid input - ("required_supported_none", str | None, ..., "text", None), - # - None allowed - ("required_supported_none", str | None, ..., None, None), - # - Missing required parameter is not allowed - # TODO: Fix this test case: property is marked as nullable because it can be None, but it can't be missing because it is required - # ("required_supported_none", str | None, ..., ..., "Argument param is required"), - pytest.param( - "required_supported_none", - str | None, - ..., - ..., - "Argument param is required", - marks=pytest.mark.skip(reason="TODO: Fix this test case"), - ), - # - # Optional parameters (has default, doesn't support None) - # - Valid input - ("optional_unsupported_none", str, "default", "text", None), - # - None not allowed - # TODO: Fix this test case: property is marked as nullable because it has a default value, but it can't be None - # ("optional_unsupported_none", str, "default", None, "Argument param has type 'null' but should be 'string'"), - pytest.param( - "optional_unsupported_none", - str, - "default", - None, - "Argument param has type 'null' but should be 'string'", - marks=pytest.mark.skip(reason="TODO: Fix this test case"), - ), - # - Missing optional parameter is allowed - ("optional_unsupported_none", str, "default", ..., None), - # - # Optional and supports None parameters with string default - # - Valid input - ("optional_supported_none_str_default", str | None, "default", "text", None), - # - None allowed - ("optional_supported_none_str_default", str | None, "default", None, None), - # - Missing optional parameter is allowed - ("optional_supported_none_str_default", str | None, "default", ..., None), - # - # Optional and supports None parameters with None default - # - Valid input - ("optional_supported_none_none_default", str | None, None, "text", None), - # - None allowed - ("optional_supported_none_none_default", str | None, None, None, None), - # - Missing optional parameter is allowed - ("optional_supported_none_none_default", str | None, None, ..., None), - ], -) -def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): - """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" - - # Create a tool with the appropriate signature - if default is ...: # Using Ellipsis to indicate no default value - - @tool - def test_tool(param: type_hint) -> str: - """Test tool. - - Args: - param: Input param - """ - return str(param) if param is not None else "NULL" - else: - - @tool - def test_tool(param: type_hint = default) -> str: - """Test tool. - - Args: - param: Input param. - """ - return str(param) if param is not None else "NULL" - # Test with the input dictionary - input_dict = {"param": input_value} if input_value is not ... else {} + # Hub download options were forwarded to the download layer... + _, download_kwargs = mock_download.call_args + assert download_kwargs["cache_dir"] == "/tmp/cache" + assert download_kwargs["local_files_only"] is True + assert download_kwargs["revision"] == "main" - if expected_error_message: - with pytest.raises((ValueError, TypeError), match=expected_error_message): - validate_tool_arguments(test_tool, input_dict) - else: - # Should not raise any exception - validate_tool_arguments(test_tool, input_dict) + # ...but not to the tool constructor, which only received its own kwarg. + assert restored.suffix == "suffix-value" + assert restored(7) == 7 From f736a64704d23dff314bff37e3c83f1539423d9d Mon Sep 17 00:00:00 2001 From: TINGyu123644 <2663180454@qq.com> Date: Sat, 10 Oct 2026 19:53:43 +0800 Subject: [PATCH 2/4] style(tools): normalize line endings to LF The prior commit wrote src/smolagents/tools.py and tests/test_tools.py with CRLF line endings, turning the PR diff into full-file noise. Normalize both files to LF so only the actual Tool.from_hub change remains in the diff. --- src/smolagents/tools.py | 2842 +++++++++++++++++++-------------------- tests/test_tools.py | 2138 ++++++++++++++--------------- 2 files changed, 2490 insertions(+), 2490 deletions(-) diff --git a/src/smolagents/tools.py b/src/smolagents/tools.py index 911fe7cdf1..bbfe14c07d 100644 --- a/src/smolagents/tools.py +++ b/src/smolagents/tools.py @@ -1,1422 +1,1422 @@ -#!/usr/bin/env python -# coding=utf-8 - -# Copyright 2024 The HuggingFace Inc. team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -from __future__ import annotations - -import ast -import inspect -import json -import logging -import os -import sys -import tempfile -import textwrap -import types -import warnings -from abc import ABC, abstractmethod -from collections.abc import Callable -from contextlib import contextmanager -from functools import wraps -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from huggingface_hub import ( - CommitOperationAdd, - create_commit, - create_repo, - get_collection, - hf_hub_download, - metadata_update, -) - -from ._function_type_hints_utils import ( - TypeHintParsingException, - _convert_type_hints_to_json_schema, - _get_json_schema_type, - get_imports, - get_json_schema, -) -from .agent_types import AgentAudio, AgentImage, handle_agent_input_types, handle_agent_output_types -from .tool_validation import MethodChecker, validate_tool_attributes -from .utils import ( - BASE_BUILTIN_MODULES, - _is_package_available, - get_source, - instance_to_source, - is_valid_name, -) - - -if TYPE_CHECKING: - import mcp - - -logger = logging.getLogger(__name__) - - -def validate_after_init(cls): - original_init = cls.__init__ - - @wraps(original_init) - def new_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - self.validate_arguments() - - cls.__init__ = new_init - return cls - - -AUTHORIZED_TYPES = [ - "string", - "boolean", - "integer", - "number", - "image", - "audio", - "array", - "object", - "any", - "null", -] - -CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} - - -class BaseTool(ABC): - name: str - - @abstractmethod - def __call__(self, *args, **kwargs) -> Any: - pass - - -class Tool(BaseTool): - """ - A base class for the functions used by the agent. Subclass this and implement the `forward` method as well as the - following class attributes: - - - **description** (`str`) -- A short description of what your tool does, the inputs it expects and the output(s) it - will return. For instance 'This is a tool that downloads a file from a `url`. It takes the `url` as input, and - returns the text contained in the file'. - - **name** (`str`) -- A performative name that will be used for your tool in the prompt to the agent. For instance - `"text-classifier"` or `"image_generator"`. - - **inputs** (`Dict[str, Dict[str, Union[str, type, bool]]]`) -- The dict of modalities expected for the inputs. - It has one `type`key and a `description`key. - This is used by `launch_gradio_demo` or to make a nice space from your tool, and also can be used in the generated - description for your tool. - - **output_type** (`type`) -- The type of the tool output. This is used by `launch_gradio_demo` - or to make a nice space from your tool, and also can be used in the generated description for your tool. - - **output_schema** (`Dict[str, Any]`, *optional*) -- The JSON schema defining the expected structure of the tool output. - This can be included in system prompts to help agents understand the expected output format. Note: This is currently - used for informational purposes only and does not perform actual output validation. - - You can also override the method [`~Tool.setup`] if your tool has an expensive operation to perform before being - usable (such as loading a model). [`~Tool.setup`] will be called the first time you use your tool, but not at - instantiation. - """ - - name: str - description: str - inputs: dict[str, dict[str, str | type | bool]] - output_type: str - output_schema: dict[str, Any] | None = None - - def __init__(self, *args, **kwargs): - self.is_initialized = False - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - validate_after_init(cls) - - def validate_arguments(self): - required_attributes = { - "description": str, - "name": str, - "inputs": dict, - "output_type": str, - } - # Validate class attributes - for attr, expected_type in required_attributes.items(): - attr_value = getattr(self, attr, None) - if attr_value is None: - raise TypeError(f"You must set an attribute {attr}.") - if not isinstance(attr_value, expected_type): - raise TypeError( - f"Attribute {attr} should have type {expected_type.__name__}, got {type(attr_value)} instead." - ) - - # Validate optional output_schema attribute - output_schema = getattr(self, "output_schema", None) - if output_schema is not None and not isinstance(output_schema, dict): - raise TypeError(f"Attribute output_schema should have type dict, got {type(output_schema)} instead.") - - # - Validate name - if not is_valid_name(self.name): - raise Exception( - f"Invalid Tool name '{self.name}': must be a valid Python identifier and not a reserved keyword" - ) - # Validate inputs - for input_name, input_content in self.inputs.items(): - assert isinstance(input_content, dict), f"Input '{input_name}' should be a dictionary." - assert "type" in input_content and "description" in input_content, ( - f"Input '{input_name}' should have keys 'type' and 'description', has only {list(input_content.keys())}." - ) - # Get input_types as a list, whether from a string or list - if isinstance(input_content["type"], str): - input_types = [input_content["type"]] - elif isinstance(input_content["type"], list): - input_types = input_content["type"] - # Check if all elements are strings - if not all(isinstance(t, str) for t in input_types): - raise TypeError( - f"Input '{input_name}': when type is a list, all elements must be strings, got {input_content['type']}" - ) - else: - raise TypeError( - f"Input '{input_name}': type must be a string or list of strings, got {type(input_content['type']).__name__}" - ) - # Check all types are authorized - invalid_types = [t for t in input_types if t not in AUTHORIZED_TYPES] - if invalid_types: - raise ValueError(f"Input '{input_name}': types {invalid_types} must be one of {AUTHORIZED_TYPES}") - # Validate output type - assert getattr(self, "output_type", None) in AUTHORIZED_TYPES - - # Validate forward function signature, except for Tools that use a "generic" signature (PipelineTool, SpaceToolWrapper, LangChainToolWrapper) - if not ( - hasattr(self, "skip_forward_signature_validation") - and getattr(self, "skip_forward_signature_validation") is True - ): - signature = inspect.signature(self.forward) - actual_keys = set(key for key in signature.parameters.keys() if key != "self") - expected_keys = set(self.inputs.keys()) - if actual_keys != expected_keys: - raise Exception( - f"In tool '{self.name}', 'forward' method parameters were {actual_keys}, but expected {expected_keys}. " - f"It should take 'self' as its first argument, then its next arguments should match the keys of tool attribute 'inputs'." - ) - - json_schema = _convert_type_hints_to_json_schema(self.forward, error_on_missing_type_hints=False)[ - "properties" - ] # This function will not raise an error on missing docstrings, contrary to get_json_schema - for key, value in self.inputs.items(): - assert key in json_schema, ( - f"Input '{key}' should be present in function signature, found only {json_schema.keys()}" - ) - if "nullable" in value: - assert "nullable" in json_schema[key], ( - f"Nullable argument '{key}' in inputs should have key 'nullable' set to True in function signature." - ) - if key in json_schema and "nullable" in json_schema[key]: - assert "nullable" in value, ( - f"Nullable argument '{key}' in function signature should have key 'nullable' set to True in inputs." - ) - - def forward(self, *args, **kwargs): - raise NotImplementedError("Write this method in your subclass of `Tool`.") - - def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): - if not self.is_initialized: - self.setup() - - # Handle the arguments might be passed as a single dictionary - if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], dict): - potential_kwargs = args[0] - - # If the dictionary keys match our input parameters, convert it to kwargs - if all(key in self.inputs for key in potential_kwargs): - args = () - kwargs = potential_kwargs - - if sanitize_inputs_outputs: - args, kwargs = handle_agent_input_types(*args, **kwargs) - outputs = self.forward(*args, **kwargs) - if sanitize_inputs_outputs: - outputs = handle_agent_output_types(outputs, self.output_type) - return outputs - - def setup(self): - """ - Overwrite this method here for any operation that is expensive and needs to be executed before you start using - your tool. Such as loading a big model. - """ - self.is_initialized = True - - def to_code_prompt(self) -> str: - args_signature = ", ".join(f"{arg_name}: {arg_schema['type']}" for arg_name, arg_schema in self.inputs.items()) - - # Use dict type for tools with output schema to indicate structured return - has_schema = hasattr(self, "output_schema") and self.output_schema is not None - output_type = "dict" if has_schema else self.output_type - tool_signature = f"({args_signature}) -> {output_type}" - tool_doc = self.description - - # Add an important note for smaller models (e.g. Mistral Small, Gemma 3, etc.) to properly handle structured output. - if has_schema: - tool_doc += "\n\nImportant: This tool returns structured output! Use the JSON schema below to directly access fields like result['field_name']. NO print() statements needed to inspect the output!" - - # Add arguments documentation - if self.inputs: - args_descriptions = "\n".join( - f"{arg_name}: {arg_schema['description']}" for arg_name, arg_schema in self.inputs.items() - ) - args_doc = f"Args:\n{textwrap.indent(args_descriptions, ' ')}" - tool_doc += f"\n\n{args_doc}" - - # Add returns documentation with output schema if it exists - if has_schema: - formatted_schema = json.dumps(self.output_schema, indent=4) - indented_schema = textwrap.indent(formatted_schema, " ") - returns_doc = f"\nReturns:\n dict (structured output): This tool ALWAYS returns a dictionary that strictly adheres to the following JSON schema:\n{indented_schema}" - tool_doc += f"\n{returns_doc}" - - tool_doc = f'"""{tool_doc}\n"""' - return f"def {self.name}{tool_signature}:\n{textwrap.indent(tool_doc, ' ')}" - - def to_tool_calling_prompt(self) -> str: - return f"{self.name}: {self.description}\n Takes inputs: {self.inputs}\n Returns an output of type: {self.output_type}" - - def to_dict(self) -> dict: - """Returns a dictionary representing the tool""" - class_name = self.__class__.__name__ - if type(self).__name__ == "SimpleTool": - # Check that imports are self-contained - source_code = get_source(self.forward).replace("@tool", "") - forward_node = ast.parse(source_code) - # If tool was created using '@tool' decorator, it has only a forward pass, so it's simpler to just get its code - method_checker = MethodChecker(set()) - method_checker.visit(forward_node) - - if len(method_checker.errors) > 0: - errors = [f"- {error}" for error in method_checker.errors] - raise (ValueError(f"SimpleTool validation failed for {self.name}:\n" + "\n".join(errors))) - - forward_source_code = get_source(self.forward) - tool_code = textwrap.dedent( - f""" - from smolagents import Tool - from typing import Any, Optional - - class {class_name}(Tool): - name = "{self.name}" - description = {json.dumps(textwrap.dedent(self.description).strip())} - inputs = {repr(self.inputs)} - output_type = "{self.output_type}" - """ - ).strip() - - # Add output_schema if it exists - if hasattr(self, "output_schema") and self.output_schema is not None: - tool_code += f"\n output_schema = {repr(self.output_schema)}" - import re - - def add_self_argument(source_code: str) -> str: - """Add 'self' as first argument to a function definition if not present.""" - pattern = r"def forward\(((?!self)[^)]*)\)" - - def replacement(match): - args = match.group(1).strip() - if args: # If there are other arguments - return f"def forward(self, {args})" - return "def forward(self)" - - return re.sub(pattern, replacement, source_code) - - forward_source_code = forward_source_code.replace(self.name, "forward") - forward_source_code = add_self_argument(forward_source_code) - forward_source_code = forward_source_code.replace("@tool", "").strip() - tool_code += "\n\n" + textwrap.indent(forward_source_code, " ") - - else: # If the tool was not created by the @tool decorator, it was made by subclassing Tool - if type(self).__name__ in [ - "SpaceToolWrapper", - "LangChainToolWrapper", - "GradioToolWrapper", - ]: - raise ValueError( - "Cannot save objects created with from_space, from_langchain or from_gradio, as this would create errors." - ) - - validate_tool_attributes(self.__class__) - - tool_code = "from typing import Any, Optional\n" + instance_to_source(self, base_cls=Tool) - - requirements = {el for el in get_imports(tool_code) if el not in sys.stdlib_module_names} | {"smolagents"} - - tool_dict = {"name": self.name, "code": tool_code, "requirements": sorted(requirements)} - - # Add output_schema if it exists - if hasattr(self, "output_schema") and self.output_schema is not None: - tool_dict["output_schema"] = self.output_schema - - return tool_dict - - @classmethod - def from_dict(cls, tool_dict: dict[str, Any], **kwargs) -> "Tool": - """ - Create tool from a dictionary representation. - - Args: - tool_dict (`dict[str, Any]`): Dictionary representation of the tool. - **kwargs: Additional keyword arguments to pass to the tool's constructor. - - Returns: - `Tool`: Tool object. - """ - if "code" not in tool_dict: - raise ValueError("Tool dictionary must contain 'code' key with the tool source code") - - tool = cls.from_code(tool_dict["code"], **kwargs) - - # Set output_schema if it exists in the dictionary - if "output_schema" in tool_dict: - tool.output_schema = tool_dict["output_schema"] - - return tool - - def save(self, output_dir: str | Path, tool_file_name: str = "tool", make_gradio_app: bool = True): - """ - Saves the relevant code files for your tool so it can be pushed to the Hub. This will copy the code of your - tool in `output_dir` as well as autogenerate: - - - a `{tool_file_name}.py` file containing the logic for your tool. - If you pass `make_gradio_app=True`, this will also write: - - an `app.py` file providing a UI for your tool when it is exported to a Space with `tool.push_to_hub()` - - a `requirements.txt` containing the names of the modules used by your tool (as detected when inspecting its - code) - - Args: - output_dir (`str` or `Path`): The folder in which you want to save your tool. - tool_file_name (`str`, *optional*): The file name in which you want to save your tool. - make_gradio_app (`bool`, *optional*, defaults to True): Whether to also export a `requirements.txt` file and Gradio UI. - """ - # Ensure output directory exists - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - # Save tool file - self._write_file(output_path / f"{tool_file_name}.py", self._get_tool_code()) - if make_gradio_app: - # Save app file - self._write_file(output_path / "app.py", self._get_gradio_app_code(tool_module_name=tool_file_name)) - # Save requirements file - self._write_file(output_path / "requirements.txt", self._get_requirements()) - - def _write_file(self, file_path: Path, content: str) -> None: - """Writes content to a file with UTF-8 encoding.""" - file_path.write_text(content, encoding="utf-8") - - def push_to_hub( - self, - repo_id: str, - commit_message: str = "Upload tool", - private: bool | None = None, - token: bool | str | None = None, - create_pr: bool = False, - ) -> str: - """ - Upload the tool to the Hub. - - Parameters: - repo_id (`str`): - The name of the repository you want to push your tool to. It should contain your organization name when - pushing to a given organization. - commit_message (`str`, *optional*, defaults to `"Upload tool"`): - Message to commit while pushing. - private (`bool`, *optional*): - Whether to make the repo private. If `None` (default), the repo will be public unless the organization's default is private. This value is ignored if the repo already exists. - token (`bool` or `str`, *optional*): - The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated - when running `huggingface-cli login` (stored in `~/.huggingface`). - create_pr (`bool`, *optional*, defaults to `False`): - Whether to create a PR with the uploaded files or directly commit. - """ - # Initialize repository - repo_id = self._initialize_hub_repo(repo_id, token, private) - # Prepare files for commit - additions = self._prepare_hub_files() - # Create commit - return create_commit( - repo_id=repo_id, - operations=additions, - commit_message=commit_message, - token=token, - create_pr=create_pr, - repo_type="space", - ) - - @staticmethod - def _initialize_hub_repo(repo_id: str, token: bool | str | None, private: bool | None) -> str: - """Initialize repository on Hugging Face Hub.""" - repo_url = create_repo( - repo_id=repo_id, - token=token, - private=private, - exist_ok=True, - repo_type="space", - space_sdk="gradio", - ) - metadata_update(repo_url.repo_id, {"tags": ["smolagents", "tool"]}, repo_type="space", token=token) - return repo_url.repo_id - - def _prepare_hub_files(self) -> list: - """Prepare files for Hub commit.""" - additions = [ - # Add tool code - CommitOperationAdd( - path_in_repo="tool.py", - path_or_fileobj=self._get_tool_code().encode(), - ), - # Add Gradio app - CommitOperationAdd( - path_in_repo="app.py", - path_or_fileobj=self._get_gradio_app_code().encode(), - ), - # Add requirements - CommitOperationAdd( - path_in_repo="requirements.txt", - path_or_fileobj=self._get_requirements().encode(), - ), - ] - return additions - - def _get_tool_code(self) -> str: - """Get the tool's code.""" - return self.to_dict()["code"] - - def _get_gradio_app_code(self, tool_module_name: str = "tool") -> str: - """Get the Gradio app code.""" - class_name = self.__class__.__name__ - return textwrap.dedent( - f"""\ - from smolagents import launch_gradio_demo - from {tool_module_name} import {class_name} - - tool = {class_name}() - launch_gradio_demo(tool) - """ - ) - - def _get_requirements(self) -> str: - """Get the requirements.""" - return "\n".join(self.to_dict()["requirements"]) - - @classmethod - def from_hub( - cls, - repo_id: str, - token: str | None = None, - trust_remote_code: bool = False, - **kwargs, - ): - """ - Loads a tool defined on the Hub. - - - - Loading a tool from the Hub means that you'll download the tool and execute it locally. - ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when - installing a package using pip/npm/apt. - - - - Args: - repo_id (`str`): - The name of the Space repo on the Hub where your tool is defined. - token (`str`, *optional*): - The token to identify you on hf.co. If unset, will use the token generated when running - `huggingface-cli login` (stored in `~/.huggingface`). - trust_remote_code(`str`, *optional*, defaults to False): - This flags marks that you understand the risk of running remote code and that you trust this tool. - If not setting this to True, loading the tool from Hub will fail. - kwargs (additional keyword arguments, *optional*): - Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as - `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the - others will be passed along to its init. - """ - if not trust_remote_code: - raise ValueError( - "Loading a tool from Hub requires to acknowledge you trust its code: to do so, pass `trust_remote_code=True`." - ) - - # Get the tool's tool.py file. - tool_file = hf_hub_download( - repo_id, - "tool.py", - token=token, - repo_type="space", - cache_dir=kwargs.pop("cache_dir", None), - force_download=kwargs.pop("force_download", None), - proxies=kwargs.pop("proxies", None), - revision=kwargs.pop("revision", None), - subfolder=kwargs.pop("subfolder", None), - local_files_only=kwargs.pop("local_files_only", None), - ) - - tool_code = Path(tool_file).read_text() - return Tool.from_code(tool_code, **kwargs) - - @classmethod - def from_code(cls, tool_code: str, **kwargs): - module = types.ModuleType("dynamic_tool") - - exec(tool_code, module.__dict__) - - # Find the Tool subclass - tool_class = next( - ( - obj - for _, obj in inspect.getmembers(module, inspect.isclass) - if issubclass(obj, Tool) and obj is not Tool - ), - None, - ) - - if tool_class is None: - raise ValueError("No Tool subclass found in the code.") - - if not isinstance(tool_class.inputs, dict): - tool_class.inputs = ast.literal_eval(tool_class.inputs) - - # Handle output_schema if it exists and is a string representation - if hasattr(tool_class, "output_schema") and isinstance(tool_class.output_schema, str): - tool_class.output_schema = ast.literal_eval(tool_class.output_schema) - - return tool_class(**kwargs) - - @staticmethod - def from_space( - space_id: str, - name: str, - description: str = "", - api_name: str | None = None, - token: str | None = None, - ): - """ - Creates a [`Tool`] from a Space given its id on the Hub. - - Args: - space_id (`str`): - The id of the Space on the Hub. - name (`str`): - The name of the tool. - description (`str`): - The description of the tool. - api_name (`str`, *optional*): - The specific api_name to use, if the space has several tabs. If not precised, will default to the first available api. - token (`str`, *optional*): - Add your token to access private spaces or increase your GPU quotas. - Returns: - [`Tool`]: - The Space, as a tool. - - Examples: - ```py - >>> image_generator = Tool.from_space( - ... space_id="black-forest-labs/FLUX.1-schnell", - ... name="image-generator", - ... description="Generate an image from a prompt" - ... ) - >>> image = image_generator("Generate an image of a cool surfer in Tahiti") - ``` - ```py - >>> face_swapper = Tool.from_space( - ... "tuan2308/face-swap", - ... "face_swapper", - ... "Tool that puts the face shown on the first image on the second image. You can give it paths to images.", - ... ) - >>> image = face_swapper('./aymeric.jpeg', './ruth.jpg') - ``` - """ - from gradio_client import Client, handle_file - - class SpaceToolWrapper(Tool): - skip_forward_signature_validation = True - - def __init__( - self, - space_id: str, - name: str, - description: str = "", - api_name: str | None = None, - token: str | None = None, - ): - self.name = name - self.description = description - self.client = Client(space_id, hf_token=token) - space_api = self.client.view_api(return_format="dict", print_info=False) - assert isinstance(space_api, dict) - space_description = space_api["named_endpoints"] - - # If api_name is not defined, take the first of the available APIs for this space - if api_name is None: - api_name = list(space_description.keys())[0] - warnings.warn( - f"Since `api_name` was not defined, it was automatically set to the first available API: `{api_name}`." - ) - self.api_name = api_name - - try: - space_description_api = space_description[api_name] - except KeyError: - raise KeyError(f"Could not find specified {api_name=} among available api names.") - self.inputs = {} - for parameter in space_description_api["parameters"]: - parameter_type = parameter["type"]["type"] - if parameter_type == "object": - parameter_type = "any" - self.inputs[parameter["parameter_name"]] = { - "type": parameter_type, - "description": parameter["python_type"]["description"], - "nullable": parameter["parameter_has_default"], - } - output_component = space_description_api["returns"][0]["component"] - if output_component == "Image": - self.output_type = "image" - elif output_component == "Audio": - self.output_type = "audio" - else: - self.output_type = "any" - self.is_initialized = True - - def sanitize_argument_for_prediction(self, arg): - from gradio_client.utils import is_http_url_like - from PIL.Image import Image - - if isinstance(arg, Image): - temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False) - arg.save(temp_file.name) - arg = temp_file.name - if ( - (isinstance(arg, str) and os.path.isfile(arg)) - or (isinstance(arg, Path) and arg.exists() and arg.is_file()) - or is_http_url_like(arg) - ): - arg = handle_file(arg) - return arg - - def forward(self, *args, **kwargs): - # Preprocess args and kwargs: - args = list(args) - for i, arg in enumerate(args): - args[i] = self.sanitize_argument_for_prediction(arg) - for arg_name, arg in kwargs.items(): - kwargs[arg_name] = self.sanitize_argument_for_prediction(arg) - - output = self.client.predict(*args, api_name=self.api_name, **kwargs) - if isinstance(output, tuple) or isinstance(output, list): - if isinstance(output[1], str): - raise ValueError("The space returned this message: " + output[1]) - output = output[ - 0 - ] # Sometime the space also returns the generation seed, in which case the result is at index 0 - IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"] - AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".flac"] - if isinstance(output, str) and any([output.endswith(ext) for ext in IMAGE_EXTENSIONS]): - output = AgentImage(output) - elif isinstance(output, str) and any([output.endswith(ext) for ext in AUDIO_EXTENSIONS]): - output = AgentAudio(output) - return output - - return SpaceToolWrapper( - space_id=space_id, - name=name, - description=description, - api_name=api_name, - token=token, - ) - - @staticmethod - def from_gradio(gradio_tool): - """ - Creates a [`Tool`] from a gradio tool. - """ - import inspect - - class GradioToolWrapper(Tool): - def __init__(self, _gradio_tool): - self.name = _gradio_tool.name - self.description = _gradio_tool.description - self.output_type = "string" - self._gradio_tool = _gradio_tool - func_args = list(inspect.signature(_gradio_tool.run).parameters.items()) - self.inputs = { - key: {"type": CONVERSION_DICT[value.annotation], "description": ""} for key, value in func_args - } - self.forward = self._gradio_tool.run - - return GradioToolWrapper(gradio_tool) - - @staticmethod - def from_langchain(langchain_tool): - """ - Creates a [`Tool`] from a langchain tool. - """ - - class LangChainToolWrapper(Tool): - skip_forward_signature_validation = True - - def __init__(self, _langchain_tool): - self.name = _langchain_tool.name.lower() - self.description = _langchain_tool.description - self.inputs = _langchain_tool.args.copy() - for input_content in self.inputs.values(): - if "title" in input_content: - input_content.pop("title") - input_content["description"] = "" - self.output_type = "string" - self.langchain_tool = _langchain_tool - self.is_initialized = True - - def forward(self, *args, **kwargs): - tool_input = kwargs.copy() - for index, argument in enumerate(args): - if index < len(self.inputs): - input_key = next(iter(self.inputs)) - tool_input[input_key] = argument - return self.langchain_tool.run(tool_input) - - return LangChainToolWrapper(langchain_tool) - - -def launch_gradio_demo(tool: Tool): - """ - Launches a gradio demo for a tool. The corresponding tool class needs to properly implement the class attributes - `inputs` and `output_type`. - - Args: - tool (`Tool`): The tool for which to launch the demo. - """ - try: - import gradio as gr - except ImportError: - raise ImportError("Gradio should be installed in order to launch a gradio demo.") - - TYPE_TO_COMPONENT_CLASS_MAPPING = { - "boolean": gr.Checkbox, - "image": gr.Image, - "audio": gr.Audio, - "string": gr.Textbox, - "integer": gr.Number, - "number": gr.Number, - } - - def tool_forward(*args, **kwargs): - return tool(*args, sanitize_inputs_outputs=True, **kwargs) - - tool_forward.__signature__ = inspect.signature(tool.forward) - - gradio_inputs = [] - for input_name, input_details in tool.inputs.items(): - input_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[input_details["type"]] - new_component = input_gradio_component_class(label=input_name) - gradio_inputs.append(new_component) - - output_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[tool.output_type] - gradio_output = output_gradio_component_class(label="Output") - - gr.Interface( - fn=tool_forward, - inputs=gradio_inputs, - outputs=gradio_output, - title=tool.name, - description=tool.description, - api_name=tool.name, - ).launch() - - -def load_tool( - repo_id, - model_repo_id: str | None = None, - token: str | None = None, - trust_remote_code: bool = False, - **kwargs, -): - """ - Main function to quickly load a tool from the Hub. - - - - Loading a tool means that you'll download the tool and execute it locally. - ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when - installing a package using pip/npm/apt. - - - - Args: - repo_id (`str`): - Space repo ID of a tool on the Hub. - model_repo_id (`str`, *optional*): - Use this argument to use a different model than the default one for the tool you selected. - token (`str`, *optional*): - The token to identify you on hf.co. If unset, will use the token generated when running `huggingface-cli - login` (stored in `~/.huggingface`). - trust_remote_code (`bool`, *optional*, defaults to False): - This needs to be accepted in order to load a tool from Hub. - kwargs (additional keyword arguments, *optional*): - Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as - `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the others - will be passed along to its init. - """ - return Tool.from_hub( - repo_id, - model_repo_id=model_repo_id, - token=token, - trust_remote_code=trust_remote_code, - **kwargs, - ) - - -def add_description(description): - """ - A decorator that adds a description to a function. - """ - - def inner(func): - func.description = description - func.name = func.__name__ - return func - - return inner - - -class ToolCollection: - """ - Tool collections enable loading a collection of tools in the agent's toolbox. - - Collections can be loaded from a collection in the Hub or from an MCP server, see: - - [`ToolCollection.from_hub`] - - [`ToolCollection.from_mcp`] - - For example and usage, see: [`ToolCollection.from_hub`] and [`ToolCollection.from_mcp`] - """ - - def __init__(self, tools: list[Tool]): - self.tools = tools - - @classmethod - def from_hub( - cls, - collection_slug: str, - token: str | None = None, - trust_remote_code: bool = False, - ) -> "ToolCollection": - """Loads a tool collection from the Hub. - - it adds a collection of tools from all Spaces in the collection to the agent's toolbox - - > [!NOTE] - > Only Spaces will be fetched, so you can feel free to add models and datasets to your collection if you'd - > like for this collection to showcase them. - - Args: - collection_slug (str): The collection slug referencing the collection. - token (str, *optional*): The authentication token if the collection is private. - trust_remote_code (bool, *optional*, defaults to False): Whether to trust the remote code. - - Returns: - ToolCollection: A tool collection instance loaded with the tools. - - Example: - ```py - >>> from smolagents import ToolCollection, CodeAgent - - >>> image_tool_collection = ToolCollection.from_hub("huggingface-tools/diffusion-tools-6630bb19a942c2306a2cdb6f") - >>> agent = CodeAgent(tools=[*image_tool_collection.tools], add_base_tools=True) - - >>> agent.run("Please draw me a picture of rivers and lakes.") - ``` - """ - _collection = get_collection(collection_slug, token=token) - _hub_repo_ids = {item.item_id for item in _collection.items if item.item_type == "space"} - - tools = [Tool.from_hub(repo_id, token, trust_remote_code) for repo_id in _hub_repo_ids] - - return cls(tools) - - @classmethod - @contextmanager - def from_mcp( - cls, - server_parameters: "mcp.StdioServerParameters" | dict, - trust_remote_code: bool = False, - structured_output: bool | None = None, - ) -> "ToolCollection": - """Automatically load a tool collection from an MCP server. - - This method supports Stdio, Streamable HTTP, and legacy HTTP+SSE MCP servers. Look at the `server_parameters` - argument for more details on how to connect to each MCP server. - - Note: a separate thread will be spawned to run an asyncio event loop handling - the MCP server. - - Args: - server_parameters (`mcp.StdioServerParameters` or `dict`): - Configuration parameters to connect to the MCP server. This can be: - - - An instance of `mcp.StdioServerParameters` for connecting a Stdio MCP server via standard input/output using a subprocess. - - - A `dict` with at least: - - "url": URL of the server. - - "transport": Transport protocol to use, one of: - - "streamable-http": Streamable HTTP transport (default). - - "sse": Legacy HTTP+SSE transport (deprecated). - trust_remote_code (`bool`, *optional*, defaults to `False`): - Whether to trust the execution of code from tools defined on the MCP server. - This option should only be set to `True` if you trust the MCP server, - and understand the risks associated with running remote code on your local machine. - If set to `False`, loading tools from MCP will fail. - structured_output (`bool`, *optional*, defaults to `False`): - Whether to enable structured output features for MCP tools. If True, enables: - - Support for outputSchema in MCP tools - - Structured content handling (structuredContent from MCP responses) - - JSON parsing fallback for structured data - If False, uses the original simple text-only behavior for backwards compatibility. - - Returns: - ToolCollection: A tool collection instance. - - Example with a Stdio MCP server: - ```py - >>> import os - >>> from smolagents import ToolCollection, CodeAgent, InferenceClientModel - >>> from mcp import StdioServerParameters - - >>> model = InferenceClientModel() - - >>> server_parameters = StdioServerParameters( - >>> command="uvx", - >>> args=["--quiet", "pubmedmcp@0.1.3"], - >>> env={"UV_PYTHON": "3.12", **os.environ}, - >>> ) - - >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - - Example with structured output enabled: - ```py - >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True, structured_output=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - - Example with a Streamable HTTP MCP server: - ```py - >>> with ToolCollection.from_mcp({"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True) as tool_collection: - >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) - >>> agent.run("Please find a remedy for hangover.") - ``` - """ - # Handle future warning for structured_output default value change - if structured_output is None: - warnings.warn( - "Parameter 'structured_output' was not specified. " - "Currently it defaults to False, but in version 1.25, the default will change to True. " - "To suppress this warning, explicitly set structured_output=True (new behavior) or structured_output=False (legacy behavior). " - "See documentation at https://huggingface.co/docs/smolagents/tutorials/tools#structured-output-and-output-schema-support for more details.", - FutureWarning, - stacklevel=2, - ) - structured_output = False - - try: - from mcpadapt.core import MCPAdapt - from mcpadapt.smolagents_adapter import SmolAgentsAdapter - except ImportError: - raise ImportError( - """Please install 'mcp' extra to use ToolCollection.from_mcp: `pip install 'smolagents[mcp]'`.""" - ) - if isinstance(server_parameters, dict): - transport = server_parameters.get("transport") - if transport is None: - transport = "streamable-http" - server_parameters["transport"] = transport - if transport not in {"sse", "streamable-http"}: - raise ValueError( - f"Unsupported transport: {transport}. Supported transports are 'streamable-http' and 'sse'." - ) - if not trust_remote_code: - raise ValueError( - "Loading tools from MCP requires you to acknowledge you trust the MCP server, " - "as it will execute code on your local machine: pass `trust_remote_code=True`." - ) - with MCPAdapt(server_parameters, SmolAgentsAdapter(structured_output=structured_output)) as tools: - yield cls(tools) - - -def tool(tool_function: Callable) -> Tool: - """ - Convert a function into an instance of a dynamically created Tool subclass. - - Args: - tool_function (`Callable`): Function to convert into a Tool subclass. - Should have type hints for each input and a type hint for the output. - Should also have a docstring including the description of the function - and an 'Args:' part where each argument is described. - """ - tool_json_schema = get_json_schema(tool_function)["function"] - if "return" not in tool_json_schema: - if len(tool_json_schema["parameters"]["properties"]) == 0: - tool_json_schema["return"] = {"type": "null"} - else: - raise TypeHintParsingException( - "Tool return type not found: make sure your function has a return type hint!" - ) - - class SimpleTool(Tool): - def __init__(self): - self.is_initialized = True - - # Set the class attributes - SimpleTool.name = tool_json_schema["name"] - SimpleTool.description = tool_json_schema["description"] - SimpleTool.inputs = tool_json_schema["parameters"]["properties"] - SimpleTool.output_type = tool_json_schema["return"]["type"] - - # Set output_schema if it exists in the JSON schema - if "output_schema" in tool_json_schema: - SimpleTool.output_schema = tool_json_schema["output_schema"] - elif "return" in tool_json_schema and "schema" in tool_json_schema["return"]: - SimpleTool.output_schema = tool_json_schema["return"]["schema"] - - @wraps(tool_function) - def wrapped_function(*args, **kwargs): - return tool_function(*args, **kwargs) - - # Bind the copied function to the forward method - SimpleTool.forward = staticmethod(wrapped_function) - - # Get the signature parameters of the tool function - sig = inspect.signature(tool_function) - # - Add "self" as first parameter to tool_function signature - new_sig = sig.replace( - parameters=[inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] + list(sig.parameters.values()) - ) - # - Set the signature of the forward method - SimpleTool.forward.__signature__ = new_sig - - # Create and attach the source code of the dynamically created tool class and forward method - # - Get the source code of tool_function - tool_source = textwrap.dedent(inspect.getsource(tool_function)) - # - Remove the tool decorator and function definition line - lines = tool_source.splitlines() - tree = ast.parse(tool_source) - # - Find function definition - func_node = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)), None) - if not func_node: - raise ValueError( - f"No function definition found in the provided source of {tool_function.__name__}. " - "Ensure the input is a standard function." - ) - # - Extract decorator lines - decorator_lines = "" - if func_node.decorator_list: - tool_decorators = [d for d in func_node.decorator_list if isinstance(d, ast.Name) and d.id == "tool"] - if len(tool_decorators) > 1: - raise ValueError( - f"Multiple @tool decorators found on function '{func_node.name}'. Only one @tool decorator is allowed." - ) - if len(tool_decorators) < len(func_node.decorator_list): - warnings.warn( - f"Function '{func_node.name}' has decorators other than @tool. " - "This may cause issues with serialization in the remote executor. See issue #1626." - ) - decorator_start = tool_decorators[0].end_lineno if tool_decorators else 0 - decorator_end = func_node.decorator_list[-1].end_lineno - decorator_lines = "\n".join(lines[decorator_start:decorator_end]) - # - Extract tool source body - body_start = func_node.body[0].lineno - 1 # AST lineno starts at 1 - tool_source_body = "\n".join(lines[body_start:]) - # - Create the forward method source, including def line and indentation - forward_method_source = f"def forward{new_sig}:\n{tool_source_body}" - # - Create the class source - indent = " " * 4 # for class method - class_source = ( - textwrap.dedent(f""" - class SimpleTool(Tool): - name: str = "{tool_json_schema["name"]}" - description: str = {json.dumps(textwrap.dedent(tool_json_schema["description"]).strip())} - inputs: dict[str, dict[str, str]] = {tool_json_schema["parameters"]["properties"]} - output_type: str = "{tool_json_schema["return"]["type"]}" - - def __init__(self): - self.is_initialized = True - - """) - + textwrap.indent(decorator_lines, indent) - + textwrap.indent(forward_method_source, indent) - ) - # - Store the source code on both class and method for inspection - SimpleTool.__source__ = class_source - SimpleTool.forward.__source__ = forward_method_source - - simple_tool = SimpleTool() - return simple_tool - - -class PipelineTool(Tool): - """ - A [`Tool`] tailored towards Transformer models. On top of the class attributes of the base class [`Tool`], you will - need to specify: - - - **model_class** (`type`) -- The class to use to load the model in this tool. - - **default_checkpoint** (`str`) -- The default checkpoint that should be used when the user doesn't specify one. - - **pre_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the - pre-processor - - **post_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the - post-processor (when different from the pre-processor). - - Args: - model (`str` or [`transformers.PreTrainedModel`], *optional*): - The name of the checkpoint to use for the model, or the instantiated model. If unset, will default to the - value of the class attribute `default_checkpoint`. - pre_processor (`str` or `Any`, *optional*): - The name of the checkpoint to use for the pre-processor, or the instantiated pre-processor (can be a - tokenizer, an image processor, a feature extractor or a processor). Will default to the value of `model` if - unset. - post_processor (`str` or `Any`, *optional*): - The name of the checkpoint to use for the post-processor, or the instantiated pre-processor (can be a - tokenizer, an image processor, a feature extractor or a processor). Will default to the `pre_processor` if - unset. - device (`int`, `str` or `torch.device`, *optional*): - The device on which to execute the model. Will default to any accelerator available (GPU, MPS etc...), the - CPU otherwise. - device_map (`str` or `dict`, *optional*): - If passed along, will be used to instantiate the model. - model_kwargs (`dict`, *optional*): - Any keyword argument to send to the model instantiation. - token (`str`, *optional*): - The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated when - running `huggingface-cli login` (stored in `~/.huggingface`). - hub_kwargs (additional keyword arguments, *optional*): - Any additional keyword argument to send to the methods that will load the data from the Hub. - """ - - pre_processor_class = None - model_class = None - post_processor_class = None - default_checkpoint = None - description = "This is a pipeline tool" - name = "pipeline" - inputs = {"prompt": str} - output_type = str - skip_forward_signature_validation = True - - def __init__( - self, - model=None, - pre_processor=None, - post_processor=None, - device=None, - device_map=None, - model_kwargs=None, - token=None, - **hub_kwargs, - ): - if not _is_package_available("accelerate") or not _is_package_available("torch"): - raise ModuleNotFoundError( - "Please install 'transformers' extra to use a PipelineTool: `pip install 'smolagents[transformers]'`" - ) - - if model is None: - if self.default_checkpoint is None: - raise ValueError("This tool does not implement a default checkpoint, you need to pass one.") - model = self.default_checkpoint - if pre_processor is None: - pre_processor = model - - self.model = model - self.pre_processor = pre_processor - self.post_processor = post_processor - self.device = device - self.device_map = device_map - self.model_kwargs = {} if model_kwargs is None else model_kwargs - if device_map is not None: - self.model_kwargs["device_map"] = device_map - self.hub_kwargs = hub_kwargs - self.hub_kwargs["token"] = token - - super().__init__() - - def setup(self): - """ - Instantiates the `pre_processor`, `model` and `post_processor` if necessary. - """ - if isinstance(self.pre_processor, str): - if self.pre_processor_class is None: - from transformers import AutoProcessor - - self.pre_processor_class = AutoProcessor - self.pre_processor = self.pre_processor_class.from_pretrained(self.pre_processor, **self.hub_kwargs) - - if isinstance(self.model, str): - self.model = self.model_class.from_pretrained(self.model, **self.model_kwargs, **self.hub_kwargs) - - if self.post_processor is None: - self.post_processor = self.pre_processor - elif isinstance(self.post_processor, str): - if self.post_processor_class is None: - from transformers import AutoProcessor - - self.post_processor_class = AutoProcessor - self.post_processor = self.post_processor_class.from_pretrained(self.post_processor, **self.hub_kwargs) - - if self.device is None: - if self.device_map is not None: - self.device = list(self.model.hf_device_map.values())[0] - else: - from accelerate import PartialState - - self.device = PartialState().default_device - - if self.device_map is None: - self.model.to(self.device) - - super().setup() - - def encode(self, raw_inputs): - """ - Uses the `pre_processor` to prepare the inputs for the `model`. - """ - return self.pre_processor(raw_inputs) - - def forward(self, inputs): - """ - Sends the inputs through the `model`. - """ - import torch - - with torch.no_grad(): - return self.model(**inputs) - - def decode(self, outputs): - """ - Uses the `post_processor` to decode the model output. - """ - return self.post_processor(outputs) - - def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): - import torch - from accelerate.utils import send_to_device - - if not self.is_initialized: - self.setup() - - if sanitize_inputs_outputs: - args, kwargs = handle_agent_input_types(*args, **kwargs) - encoded_inputs = self.encode(*args, **kwargs) - - tensor_inputs = {k: v for k, v in encoded_inputs.items() if isinstance(v, torch.Tensor)} - non_tensor_inputs = {k: v for k, v in encoded_inputs.items() if not isinstance(v, torch.Tensor)} - - encoded_inputs = send_to_device(tensor_inputs, self.device) - outputs = self.forward({**encoded_inputs, **non_tensor_inputs}) - outputs = send_to_device(outputs, "cpu") - decoded_outputs = self.decode(outputs) - if sanitize_inputs_outputs: - decoded_outputs = handle_agent_output_types(decoded_outputs, self.output_type) - return decoded_outputs - - -def get_tools_definition_code(tools: dict[str, Tool]) -> str: - tool_codes = [] - for tool in tools.values(): - validate_tool_attributes(tool.__class__, check_imports=False) - tool_code = instance_to_source(tool, base_cls=Tool) - tool_code = tool_code.replace("from smolagents.tools import Tool", "") - tool_code += f"\n\n{tool.name} = {tool.__class__.__name__}()\n" - tool_codes.append(tool_code) - - tool_definition_code = "\n".join([f"import {module}" for module in BASE_BUILTIN_MODULES]) - tool_definition_code += textwrap.dedent( - """ - from typing import Any - - class Tool: - def __call__(self, *args, **kwargs): - return self.forward(*args, **kwargs) - - def forward(self, *args, **kwargs): - pass # to be implemented in child class - """ - ) - tool_definition_code += "\n\n".join(tool_codes) - return tool_definition_code - - -def validate_tool_arguments(tool: Tool, arguments: Any) -> None: - """Validate tool arguments against tool's input schema. - - Checks that all provided arguments match the tool's expected input types and that - all required arguments are present. Supports both dictionary arguments and single - value arguments for tools with one input parameter. - - Args: - tool (`Tool`): Tool whose input schema will be used for validation. - arguments (`Any`): Arguments to validate. Can be a dictionary mapping - argument names to values, or a single value for tools with one input. - - - Raises: - ValueError: If an argument is not in the tool's input schema, if a required - argument is missing, or if the argument value doesn't match the expected type. - TypeError: If an argument has an incorrect type that cannot be converted - (e.g., string instead of number, excluding integer to number conversion). - - Note: - - Supports type coercion from integer to number - - Handles nullable parameters when explicitly marked in the schema - - Accepts "any" type as a wildcard that matches all types - """ - if isinstance(arguments, dict): - for key, value in arguments.items(): - if key not in tool.inputs: - raise ValueError(f"Argument {key} is not in the tool's input schema") - - actual_type = _get_json_schema_type(type(value))["type"] - expected_type = tool.inputs[key]["type"] - expected_type_is_nullable = tool.inputs[key].get("nullable", False) - - # Type is valid if it matches, is "any", or is null for nullable parameters - if ( - (actual_type != expected_type if isinstance(expected_type, str) else actual_type not in expected_type) - and expected_type != "any" - and not (actual_type == "null" and expected_type_is_nullable) - ): - if actual_type == "integer" and expected_type == "number": - continue - raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'") - - for key, schema in tool.inputs.items(): - key_is_nullable = schema.get("nullable", False) - if key not in arguments and not key_is_nullable: - raise ValueError(f"Argument {key} is required") - return None - else: - expected_type = list(tool.inputs.values())[0]["type"] - if _get_json_schema_type(type(arguments))["type"] != expected_type and not expected_type == "any": - raise TypeError(f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'") - - -__all__ = [ - "AUTHORIZED_TYPES", - "Tool", - "tool", - "load_tool", - "launch_gradio_demo", - "ToolCollection", +#!/usr/bin/env python +# coding=utf-8 + +# Copyright 2024 The HuggingFace Inc. team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from __future__ import annotations + +import ast +import inspect +import json +import logging +import os +import sys +import tempfile +import textwrap +import types +import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable +from contextlib import contextmanager +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from huggingface_hub import ( + CommitOperationAdd, + create_commit, + create_repo, + get_collection, + hf_hub_download, + metadata_update, +) + +from ._function_type_hints_utils import ( + TypeHintParsingException, + _convert_type_hints_to_json_schema, + _get_json_schema_type, + get_imports, + get_json_schema, +) +from .agent_types import AgentAudio, AgentImage, handle_agent_input_types, handle_agent_output_types +from .tool_validation import MethodChecker, validate_tool_attributes +from .utils import ( + BASE_BUILTIN_MODULES, + _is_package_available, + get_source, + instance_to_source, + is_valid_name, +) + + +if TYPE_CHECKING: + import mcp + + +logger = logging.getLogger(__name__) + + +def validate_after_init(cls): + original_init = cls.__init__ + + @wraps(original_init) + def new_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + self.validate_arguments() + + cls.__init__ = new_init + return cls + + +AUTHORIZED_TYPES = [ + "string", + "boolean", + "integer", + "number", + "image", + "audio", + "array", + "object", + "any", + "null", +] + +CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} + + +class BaseTool(ABC): + name: str + + @abstractmethod + def __call__(self, *args, **kwargs) -> Any: + pass + + +class Tool(BaseTool): + """ + A base class for the functions used by the agent. Subclass this and implement the `forward` method as well as the + following class attributes: + + - **description** (`str`) -- A short description of what your tool does, the inputs it expects and the output(s) it + will return. For instance 'This is a tool that downloads a file from a `url`. It takes the `url` as input, and + returns the text contained in the file'. + - **name** (`str`) -- A performative name that will be used for your tool in the prompt to the agent. For instance + `"text-classifier"` or `"image_generator"`. + - **inputs** (`Dict[str, Dict[str, Union[str, type, bool]]]`) -- The dict of modalities expected for the inputs. + It has one `type`key and a `description`key. + This is used by `launch_gradio_demo` or to make a nice space from your tool, and also can be used in the generated + description for your tool. + - **output_type** (`type`) -- The type of the tool output. This is used by `launch_gradio_demo` + or to make a nice space from your tool, and also can be used in the generated description for your tool. + - **output_schema** (`Dict[str, Any]`, *optional*) -- The JSON schema defining the expected structure of the tool output. + This can be included in system prompts to help agents understand the expected output format. Note: This is currently + used for informational purposes only and does not perform actual output validation. + + You can also override the method [`~Tool.setup`] if your tool has an expensive operation to perform before being + usable (such as loading a model). [`~Tool.setup`] will be called the first time you use your tool, but not at + instantiation. + """ + + name: str + description: str + inputs: dict[str, dict[str, str | type | bool]] + output_type: str + output_schema: dict[str, Any] | None = None + + def __init__(self, *args, **kwargs): + self.is_initialized = False + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + validate_after_init(cls) + + def validate_arguments(self): + required_attributes = { + "description": str, + "name": str, + "inputs": dict, + "output_type": str, + } + # Validate class attributes + for attr, expected_type in required_attributes.items(): + attr_value = getattr(self, attr, None) + if attr_value is None: + raise TypeError(f"You must set an attribute {attr}.") + if not isinstance(attr_value, expected_type): + raise TypeError( + f"Attribute {attr} should have type {expected_type.__name__}, got {type(attr_value)} instead." + ) + + # Validate optional output_schema attribute + output_schema = getattr(self, "output_schema", None) + if output_schema is not None and not isinstance(output_schema, dict): + raise TypeError(f"Attribute output_schema should have type dict, got {type(output_schema)} instead.") + + # - Validate name + if not is_valid_name(self.name): + raise Exception( + f"Invalid Tool name '{self.name}': must be a valid Python identifier and not a reserved keyword" + ) + # Validate inputs + for input_name, input_content in self.inputs.items(): + assert isinstance(input_content, dict), f"Input '{input_name}' should be a dictionary." + assert "type" in input_content and "description" in input_content, ( + f"Input '{input_name}' should have keys 'type' and 'description', has only {list(input_content.keys())}." + ) + # Get input_types as a list, whether from a string or list + if isinstance(input_content["type"], str): + input_types = [input_content["type"]] + elif isinstance(input_content["type"], list): + input_types = input_content["type"] + # Check if all elements are strings + if not all(isinstance(t, str) for t in input_types): + raise TypeError( + f"Input '{input_name}': when type is a list, all elements must be strings, got {input_content['type']}" + ) + else: + raise TypeError( + f"Input '{input_name}': type must be a string or list of strings, got {type(input_content['type']).__name__}" + ) + # Check all types are authorized + invalid_types = [t for t in input_types if t not in AUTHORIZED_TYPES] + if invalid_types: + raise ValueError(f"Input '{input_name}': types {invalid_types} must be one of {AUTHORIZED_TYPES}") + # Validate output type + assert getattr(self, "output_type", None) in AUTHORIZED_TYPES + + # Validate forward function signature, except for Tools that use a "generic" signature (PipelineTool, SpaceToolWrapper, LangChainToolWrapper) + if not ( + hasattr(self, "skip_forward_signature_validation") + and getattr(self, "skip_forward_signature_validation") is True + ): + signature = inspect.signature(self.forward) + actual_keys = set(key for key in signature.parameters.keys() if key != "self") + expected_keys = set(self.inputs.keys()) + if actual_keys != expected_keys: + raise Exception( + f"In tool '{self.name}', 'forward' method parameters were {actual_keys}, but expected {expected_keys}. " + f"It should take 'self' as its first argument, then its next arguments should match the keys of tool attribute 'inputs'." + ) + + json_schema = _convert_type_hints_to_json_schema(self.forward, error_on_missing_type_hints=False)[ + "properties" + ] # This function will not raise an error on missing docstrings, contrary to get_json_schema + for key, value in self.inputs.items(): + assert key in json_schema, ( + f"Input '{key}' should be present in function signature, found only {json_schema.keys()}" + ) + if "nullable" in value: + assert "nullable" in json_schema[key], ( + f"Nullable argument '{key}' in inputs should have key 'nullable' set to True in function signature." + ) + if key in json_schema and "nullable" in json_schema[key]: + assert "nullable" in value, ( + f"Nullable argument '{key}' in function signature should have key 'nullable' set to True in inputs." + ) + + def forward(self, *args, **kwargs): + raise NotImplementedError("Write this method in your subclass of `Tool`.") + + def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): + if not self.is_initialized: + self.setup() + + # Handle the arguments might be passed as a single dictionary + if len(args) == 1 and len(kwargs) == 0 and isinstance(args[0], dict): + potential_kwargs = args[0] + + # If the dictionary keys match our input parameters, convert it to kwargs + if all(key in self.inputs for key in potential_kwargs): + args = () + kwargs = potential_kwargs + + if sanitize_inputs_outputs: + args, kwargs = handle_agent_input_types(*args, **kwargs) + outputs = self.forward(*args, **kwargs) + if sanitize_inputs_outputs: + outputs = handle_agent_output_types(outputs, self.output_type) + return outputs + + def setup(self): + """ + Overwrite this method here for any operation that is expensive and needs to be executed before you start using + your tool. Such as loading a big model. + """ + self.is_initialized = True + + def to_code_prompt(self) -> str: + args_signature = ", ".join(f"{arg_name}: {arg_schema['type']}" for arg_name, arg_schema in self.inputs.items()) + + # Use dict type for tools with output schema to indicate structured return + has_schema = hasattr(self, "output_schema") and self.output_schema is not None + output_type = "dict" if has_schema else self.output_type + tool_signature = f"({args_signature}) -> {output_type}" + tool_doc = self.description + + # Add an important note for smaller models (e.g. Mistral Small, Gemma 3, etc.) to properly handle structured output. + if has_schema: + tool_doc += "\n\nImportant: This tool returns structured output! Use the JSON schema below to directly access fields like result['field_name']. NO print() statements needed to inspect the output!" + + # Add arguments documentation + if self.inputs: + args_descriptions = "\n".join( + f"{arg_name}: {arg_schema['description']}" for arg_name, arg_schema in self.inputs.items() + ) + args_doc = f"Args:\n{textwrap.indent(args_descriptions, ' ')}" + tool_doc += f"\n\n{args_doc}" + + # Add returns documentation with output schema if it exists + if has_schema: + formatted_schema = json.dumps(self.output_schema, indent=4) + indented_schema = textwrap.indent(formatted_schema, " ") + returns_doc = f"\nReturns:\n dict (structured output): This tool ALWAYS returns a dictionary that strictly adheres to the following JSON schema:\n{indented_schema}" + tool_doc += f"\n{returns_doc}" + + tool_doc = f'"""{tool_doc}\n"""' + return f"def {self.name}{tool_signature}:\n{textwrap.indent(tool_doc, ' ')}" + + def to_tool_calling_prompt(self) -> str: + return f"{self.name}: {self.description}\n Takes inputs: {self.inputs}\n Returns an output of type: {self.output_type}" + + def to_dict(self) -> dict: + """Returns a dictionary representing the tool""" + class_name = self.__class__.__name__ + if type(self).__name__ == "SimpleTool": + # Check that imports are self-contained + source_code = get_source(self.forward).replace("@tool", "") + forward_node = ast.parse(source_code) + # If tool was created using '@tool' decorator, it has only a forward pass, so it's simpler to just get its code + method_checker = MethodChecker(set()) + method_checker.visit(forward_node) + + if len(method_checker.errors) > 0: + errors = [f"- {error}" for error in method_checker.errors] + raise (ValueError(f"SimpleTool validation failed for {self.name}:\n" + "\n".join(errors))) + + forward_source_code = get_source(self.forward) + tool_code = textwrap.dedent( + f""" + from smolagents import Tool + from typing import Any, Optional + + class {class_name}(Tool): + name = "{self.name}" + description = {json.dumps(textwrap.dedent(self.description).strip())} + inputs = {repr(self.inputs)} + output_type = "{self.output_type}" + """ + ).strip() + + # Add output_schema if it exists + if hasattr(self, "output_schema") and self.output_schema is not None: + tool_code += f"\n output_schema = {repr(self.output_schema)}" + import re + + def add_self_argument(source_code: str) -> str: + """Add 'self' as first argument to a function definition if not present.""" + pattern = r"def forward\(((?!self)[^)]*)\)" + + def replacement(match): + args = match.group(1).strip() + if args: # If there are other arguments + return f"def forward(self, {args})" + return "def forward(self)" + + return re.sub(pattern, replacement, source_code) + + forward_source_code = forward_source_code.replace(self.name, "forward") + forward_source_code = add_self_argument(forward_source_code) + forward_source_code = forward_source_code.replace("@tool", "").strip() + tool_code += "\n\n" + textwrap.indent(forward_source_code, " ") + + else: # If the tool was not created by the @tool decorator, it was made by subclassing Tool + if type(self).__name__ in [ + "SpaceToolWrapper", + "LangChainToolWrapper", + "GradioToolWrapper", + ]: + raise ValueError( + "Cannot save objects created with from_space, from_langchain or from_gradio, as this would create errors." + ) + + validate_tool_attributes(self.__class__) + + tool_code = "from typing import Any, Optional\n" + instance_to_source(self, base_cls=Tool) + + requirements = {el for el in get_imports(tool_code) if el not in sys.stdlib_module_names} | {"smolagents"} + + tool_dict = {"name": self.name, "code": tool_code, "requirements": sorted(requirements)} + + # Add output_schema if it exists + if hasattr(self, "output_schema") and self.output_schema is not None: + tool_dict["output_schema"] = self.output_schema + + return tool_dict + + @classmethod + def from_dict(cls, tool_dict: dict[str, Any], **kwargs) -> "Tool": + """ + Create tool from a dictionary representation. + + Args: + tool_dict (`dict[str, Any]`): Dictionary representation of the tool. + **kwargs: Additional keyword arguments to pass to the tool's constructor. + + Returns: + `Tool`: Tool object. + """ + if "code" not in tool_dict: + raise ValueError("Tool dictionary must contain 'code' key with the tool source code") + + tool = cls.from_code(tool_dict["code"], **kwargs) + + # Set output_schema if it exists in the dictionary + if "output_schema" in tool_dict: + tool.output_schema = tool_dict["output_schema"] + + return tool + + def save(self, output_dir: str | Path, tool_file_name: str = "tool", make_gradio_app: bool = True): + """ + Saves the relevant code files for your tool so it can be pushed to the Hub. This will copy the code of your + tool in `output_dir` as well as autogenerate: + + - a `{tool_file_name}.py` file containing the logic for your tool. + If you pass `make_gradio_app=True`, this will also write: + - an `app.py` file providing a UI for your tool when it is exported to a Space with `tool.push_to_hub()` + - a `requirements.txt` containing the names of the modules used by your tool (as detected when inspecting its + code) + + Args: + output_dir (`str` or `Path`): The folder in which you want to save your tool. + tool_file_name (`str`, *optional*): The file name in which you want to save your tool. + make_gradio_app (`bool`, *optional*, defaults to True): Whether to also export a `requirements.txt` file and Gradio UI. + """ + # Ensure output directory exists + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + # Save tool file + self._write_file(output_path / f"{tool_file_name}.py", self._get_tool_code()) + if make_gradio_app: + # Save app file + self._write_file(output_path / "app.py", self._get_gradio_app_code(tool_module_name=tool_file_name)) + # Save requirements file + self._write_file(output_path / "requirements.txt", self._get_requirements()) + + def _write_file(self, file_path: Path, content: str) -> None: + """Writes content to a file with UTF-8 encoding.""" + file_path.write_text(content, encoding="utf-8") + + def push_to_hub( + self, + repo_id: str, + commit_message: str = "Upload tool", + private: bool | None = None, + token: bool | str | None = None, + create_pr: bool = False, + ) -> str: + """ + Upload the tool to the Hub. + + Parameters: + repo_id (`str`): + The name of the repository you want to push your tool to. It should contain your organization name when + pushing to a given organization. + commit_message (`str`, *optional*, defaults to `"Upload tool"`): + Message to commit while pushing. + private (`bool`, *optional*): + Whether to make the repo private. If `None` (default), the repo will be public unless the organization's default is private. This value is ignored if the repo already exists. + token (`bool` or `str`, *optional*): + The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated + when running `huggingface-cli login` (stored in `~/.huggingface`). + create_pr (`bool`, *optional*, defaults to `False`): + Whether to create a PR with the uploaded files or directly commit. + """ + # Initialize repository + repo_id = self._initialize_hub_repo(repo_id, token, private) + # Prepare files for commit + additions = self._prepare_hub_files() + # Create commit + return create_commit( + repo_id=repo_id, + operations=additions, + commit_message=commit_message, + token=token, + create_pr=create_pr, + repo_type="space", + ) + + @staticmethod + def _initialize_hub_repo(repo_id: str, token: bool | str | None, private: bool | None) -> str: + """Initialize repository on Hugging Face Hub.""" + repo_url = create_repo( + repo_id=repo_id, + token=token, + private=private, + exist_ok=True, + repo_type="space", + space_sdk="gradio", + ) + metadata_update(repo_url.repo_id, {"tags": ["smolagents", "tool"]}, repo_type="space", token=token) + return repo_url.repo_id + + def _prepare_hub_files(self) -> list: + """Prepare files for Hub commit.""" + additions = [ + # Add tool code + CommitOperationAdd( + path_in_repo="tool.py", + path_or_fileobj=self._get_tool_code().encode(), + ), + # Add Gradio app + CommitOperationAdd( + path_in_repo="app.py", + path_or_fileobj=self._get_gradio_app_code().encode(), + ), + # Add requirements + CommitOperationAdd( + path_in_repo="requirements.txt", + path_or_fileobj=self._get_requirements().encode(), + ), + ] + return additions + + def _get_tool_code(self) -> str: + """Get the tool's code.""" + return self.to_dict()["code"] + + def _get_gradio_app_code(self, tool_module_name: str = "tool") -> str: + """Get the Gradio app code.""" + class_name = self.__class__.__name__ + return textwrap.dedent( + f"""\ + from smolagents import launch_gradio_demo + from {tool_module_name} import {class_name} + + tool = {class_name}() + launch_gradio_demo(tool) + """ + ) + + def _get_requirements(self) -> str: + """Get the requirements.""" + return "\n".join(self.to_dict()["requirements"]) + + @classmethod + def from_hub( + cls, + repo_id: str, + token: str | None = None, + trust_remote_code: bool = False, + **kwargs, + ): + """ + Loads a tool defined on the Hub. + + + + Loading a tool from the Hub means that you'll download the tool and execute it locally. + ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when + installing a package using pip/npm/apt. + + + + Args: + repo_id (`str`): + The name of the Space repo on the Hub where your tool is defined. + token (`str`, *optional*): + The token to identify you on hf.co. If unset, will use the token generated when running + `huggingface-cli login` (stored in `~/.huggingface`). + trust_remote_code(`str`, *optional*, defaults to False): + This flags marks that you understand the risk of running remote code and that you trust this tool. + If not setting this to True, loading the tool from Hub will fail. + kwargs (additional keyword arguments, *optional*): + Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as + `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the + others will be passed along to its init. + """ + if not trust_remote_code: + raise ValueError( + "Loading a tool from Hub requires to acknowledge you trust its code: to do so, pass `trust_remote_code=True`." + ) + + # Get the tool's tool.py file. + tool_file = hf_hub_download( + repo_id, + "tool.py", + token=token, + repo_type="space", + cache_dir=kwargs.pop("cache_dir", None), + force_download=kwargs.pop("force_download", None), + proxies=kwargs.pop("proxies", None), + revision=kwargs.pop("revision", None), + subfolder=kwargs.pop("subfolder", None), + local_files_only=kwargs.pop("local_files_only", None), + ) + + tool_code = Path(tool_file).read_text() + return Tool.from_code(tool_code, **kwargs) + + @classmethod + def from_code(cls, tool_code: str, **kwargs): + module = types.ModuleType("dynamic_tool") + + exec(tool_code, module.__dict__) + + # Find the Tool subclass + tool_class = next( + ( + obj + for _, obj in inspect.getmembers(module, inspect.isclass) + if issubclass(obj, Tool) and obj is not Tool + ), + None, + ) + + if tool_class is None: + raise ValueError("No Tool subclass found in the code.") + + if not isinstance(tool_class.inputs, dict): + tool_class.inputs = ast.literal_eval(tool_class.inputs) + + # Handle output_schema if it exists and is a string representation + if hasattr(tool_class, "output_schema") and isinstance(tool_class.output_schema, str): + tool_class.output_schema = ast.literal_eval(tool_class.output_schema) + + return tool_class(**kwargs) + + @staticmethod + def from_space( + space_id: str, + name: str, + description: str = "", + api_name: str | None = None, + token: str | None = None, + ): + """ + Creates a [`Tool`] from a Space given its id on the Hub. + + Args: + space_id (`str`): + The id of the Space on the Hub. + name (`str`): + The name of the tool. + description (`str`): + The description of the tool. + api_name (`str`, *optional*): + The specific api_name to use, if the space has several tabs. If not precised, will default to the first available api. + token (`str`, *optional*): + Add your token to access private spaces or increase your GPU quotas. + Returns: + [`Tool`]: + The Space, as a tool. + + Examples: + ```py + >>> image_generator = Tool.from_space( + ... space_id="black-forest-labs/FLUX.1-schnell", + ... name="image-generator", + ... description="Generate an image from a prompt" + ... ) + >>> image = image_generator("Generate an image of a cool surfer in Tahiti") + ``` + ```py + >>> face_swapper = Tool.from_space( + ... "tuan2308/face-swap", + ... "face_swapper", + ... "Tool that puts the face shown on the first image on the second image. You can give it paths to images.", + ... ) + >>> image = face_swapper('./aymeric.jpeg', './ruth.jpg') + ``` + """ + from gradio_client import Client, handle_file + + class SpaceToolWrapper(Tool): + skip_forward_signature_validation = True + + def __init__( + self, + space_id: str, + name: str, + description: str = "", + api_name: str | None = None, + token: str | None = None, + ): + self.name = name + self.description = description + self.client = Client(space_id, hf_token=token) + space_api = self.client.view_api(return_format="dict", print_info=False) + assert isinstance(space_api, dict) + space_description = space_api["named_endpoints"] + + # If api_name is not defined, take the first of the available APIs for this space + if api_name is None: + api_name = list(space_description.keys())[0] + warnings.warn( + f"Since `api_name` was not defined, it was automatically set to the first available API: `{api_name}`." + ) + self.api_name = api_name + + try: + space_description_api = space_description[api_name] + except KeyError: + raise KeyError(f"Could not find specified {api_name=} among available api names.") + self.inputs = {} + for parameter in space_description_api["parameters"]: + parameter_type = parameter["type"]["type"] + if parameter_type == "object": + parameter_type = "any" + self.inputs[parameter["parameter_name"]] = { + "type": parameter_type, + "description": parameter["python_type"]["description"], + "nullable": parameter["parameter_has_default"], + } + output_component = space_description_api["returns"][0]["component"] + if output_component == "Image": + self.output_type = "image" + elif output_component == "Audio": + self.output_type = "audio" + else: + self.output_type = "any" + self.is_initialized = True + + def sanitize_argument_for_prediction(self, arg): + from gradio_client.utils import is_http_url_like + from PIL.Image import Image + + if isinstance(arg, Image): + temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + arg.save(temp_file.name) + arg = temp_file.name + if ( + (isinstance(arg, str) and os.path.isfile(arg)) + or (isinstance(arg, Path) and arg.exists() and arg.is_file()) + or is_http_url_like(arg) + ): + arg = handle_file(arg) + return arg + + def forward(self, *args, **kwargs): + # Preprocess args and kwargs: + args = list(args) + for i, arg in enumerate(args): + args[i] = self.sanitize_argument_for_prediction(arg) + for arg_name, arg in kwargs.items(): + kwargs[arg_name] = self.sanitize_argument_for_prediction(arg) + + output = self.client.predict(*args, api_name=self.api_name, **kwargs) + if isinstance(output, tuple) or isinstance(output, list): + if isinstance(output[1], str): + raise ValueError("The space returned this message: " + output[1]) + output = output[ + 0 + ] # Sometime the space also returns the generation seed, in which case the result is at index 0 + IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"] + AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a", ".flac"] + if isinstance(output, str) and any([output.endswith(ext) for ext in IMAGE_EXTENSIONS]): + output = AgentImage(output) + elif isinstance(output, str) and any([output.endswith(ext) for ext in AUDIO_EXTENSIONS]): + output = AgentAudio(output) + return output + + return SpaceToolWrapper( + space_id=space_id, + name=name, + description=description, + api_name=api_name, + token=token, + ) + + @staticmethod + def from_gradio(gradio_tool): + """ + Creates a [`Tool`] from a gradio tool. + """ + import inspect + + class GradioToolWrapper(Tool): + def __init__(self, _gradio_tool): + self.name = _gradio_tool.name + self.description = _gradio_tool.description + self.output_type = "string" + self._gradio_tool = _gradio_tool + func_args = list(inspect.signature(_gradio_tool.run).parameters.items()) + self.inputs = { + key: {"type": CONVERSION_DICT[value.annotation], "description": ""} for key, value in func_args + } + self.forward = self._gradio_tool.run + + return GradioToolWrapper(gradio_tool) + + @staticmethod + def from_langchain(langchain_tool): + """ + Creates a [`Tool`] from a langchain tool. + """ + + class LangChainToolWrapper(Tool): + skip_forward_signature_validation = True + + def __init__(self, _langchain_tool): + self.name = _langchain_tool.name.lower() + self.description = _langchain_tool.description + self.inputs = _langchain_tool.args.copy() + for input_content in self.inputs.values(): + if "title" in input_content: + input_content.pop("title") + input_content["description"] = "" + self.output_type = "string" + self.langchain_tool = _langchain_tool + self.is_initialized = True + + def forward(self, *args, **kwargs): + tool_input = kwargs.copy() + for index, argument in enumerate(args): + if index < len(self.inputs): + input_key = next(iter(self.inputs)) + tool_input[input_key] = argument + return self.langchain_tool.run(tool_input) + + return LangChainToolWrapper(langchain_tool) + + +def launch_gradio_demo(tool: Tool): + """ + Launches a gradio demo for a tool. The corresponding tool class needs to properly implement the class attributes + `inputs` and `output_type`. + + Args: + tool (`Tool`): The tool for which to launch the demo. + """ + try: + import gradio as gr + except ImportError: + raise ImportError("Gradio should be installed in order to launch a gradio demo.") + + TYPE_TO_COMPONENT_CLASS_MAPPING = { + "boolean": gr.Checkbox, + "image": gr.Image, + "audio": gr.Audio, + "string": gr.Textbox, + "integer": gr.Number, + "number": gr.Number, + } + + def tool_forward(*args, **kwargs): + return tool(*args, sanitize_inputs_outputs=True, **kwargs) + + tool_forward.__signature__ = inspect.signature(tool.forward) + + gradio_inputs = [] + for input_name, input_details in tool.inputs.items(): + input_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[input_details["type"]] + new_component = input_gradio_component_class(label=input_name) + gradio_inputs.append(new_component) + + output_gradio_component_class = TYPE_TO_COMPONENT_CLASS_MAPPING[tool.output_type] + gradio_output = output_gradio_component_class(label="Output") + + gr.Interface( + fn=tool_forward, + inputs=gradio_inputs, + outputs=gradio_output, + title=tool.name, + description=tool.description, + api_name=tool.name, + ).launch() + + +def load_tool( + repo_id, + model_repo_id: str | None = None, + token: str | None = None, + trust_remote_code: bool = False, + **kwargs, +): + """ + Main function to quickly load a tool from the Hub. + + + + Loading a tool means that you'll download the tool and execute it locally. + ALWAYS inspect the tool you're downloading before loading it within your runtime, as you would do when + installing a package using pip/npm/apt. + + + + Args: + repo_id (`str`): + Space repo ID of a tool on the Hub. + model_repo_id (`str`, *optional*): + Use this argument to use a different model than the default one for the tool you selected. + token (`str`, *optional*): + The token to identify you on hf.co. If unset, will use the token generated when running `huggingface-cli + login` (stored in `~/.huggingface`). + trust_remote_code (`bool`, *optional*, defaults to False): + This needs to be accepted in order to load a tool from Hub. + kwargs (additional keyword arguments, *optional*): + Additional keyword arguments that will be split in two: all arguments relevant to the Hub (such as + `cache_dir`, `revision`, `subfolder`) will be used when downloading the files for your tool, and the others + will be passed along to its init. + """ + return Tool.from_hub( + repo_id, + model_repo_id=model_repo_id, + token=token, + trust_remote_code=trust_remote_code, + **kwargs, + ) + + +def add_description(description): + """ + A decorator that adds a description to a function. + """ + + def inner(func): + func.description = description + func.name = func.__name__ + return func + + return inner + + +class ToolCollection: + """ + Tool collections enable loading a collection of tools in the agent's toolbox. + + Collections can be loaded from a collection in the Hub or from an MCP server, see: + - [`ToolCollection.from_hub`] + - [`ToolCollection.from_mcp`] + + For example and usage, see: [`ToolCollection.from_hub`] and [`ToolCollection.from_mcp`] + """ + + def __init__(self, tools: list[Tool]): + self.tools = tools + + @classmethod + def from_hub( + cls, + collection_slug: str, + token: str | None = None, + trust_remote_code: bool = False, + ) -> "ToolCollection": + """Loads a tool collection from the Hub. + + it adds a collection of tools from all Spaces in the collection to the agent's toolbox + + > [!NOTE] + > Only Spaces will be fetched, so you can feel free to add models and datasets to your collection if you'd + > like for this collection to showcase them. + + Args: + collection_slug (str): The collection slug referencing the collection. + token (str, *optional*): The authentication token if the collection is private. + trust_remote_code (bool, *optional*, defaults to False): Whether to trust the remote code. + + Returns: + ToolCollection: A tool collection instance loaded with the tools. + + Example: + ```py + >>> from smolagents import ToolCollection, CodeAgent + + >>> image_tool_collection = ToolCollection.from_hub("huggingface-tools/diffusion-tools-6630bb19a942c2306a2cdb6f") + >>> agent = CodeAgent(tools=[*image_tool_collection.tools], add_base_tools=True) + + >>> agent.run("Please draw me a picture of rivers and lakes.") + ``` + """ + _collection = get_collection(collection_slug, token=token) + _hub_repo_ids = {item.item_id for item in _collection.items if item.item_type == "space"} + + tools = [Tool.from_hub(repo_id, token, trust_remote_code) for repo_id in _hub_repo_ids] + + return cls(tools) + + @classmethod + @contextmanager + def from_mcp( + cls, + server_parameters: "mcp.StdioServerParameters" | dict, + trust_remote_code: bool = False, + structured_output: bool | None = None, + ) -> "ToolCollection": + """Automatically load a tool collection from an MCP server. + + This method supports Stdio, Streamable HTTP, and legacy HTTP+SSE MCP servers. Look at the `server_parameters` + argument for more details on how to connect to each MCP server. + + Note: a separate thread will be spawned to run an asyncio event loop handling + the MCP server. + + Args: + server_parameters (`mcp.StdioServerParameters` or `dict`): + Configuration parameters to connect to the MCP server. This can be: + + - An instance of `mcp.StdioServerParameters` for connecting a Stdio MCP server via standard input/output using a subprocess. + + - A `dict` with at least: + - "url": URL of the server. + - "transport": Transport protocol to use, one of: + - "streamable-http": Streamable HTTP transport (default). + - "sse": Legacy HTTP+SSE transport (deprecated). + trust_remote_code (`bool`, *optional*, defaults to `False`): + Whether to trust the execution of code from tools defined on the MCP server. + This option should only be set to `True` if you trust the MCP server, + and understand the risks associated with running remote code on your local machine. + If set to `False`, loading tools from MCP will fail. + structured_output (`bool`, *optional*, defaults to `False`): + Whether to enable structured output features for MCP tools. If True, enables: + - Support for outputSchema in MCP tools + - Structured content handling (structuredContent from MCP responses) + - JSON parsing fallback for structured data + If False, uses the original simple text-only behavior for backwards compatibility. + + Returns: + ToolCollection: A tool collection instance. + + Example with a Stdio MCP server: + ```py + >>> import os + >>> from smolagents import ToolCollection, CodeAgent, InferenceClientModel + >>> from mcp import StdioServerParameters + + >>> model = InferenceClientModel() + + >>> server_parameters = StdioServerParameters( + >>> command="uvx", + >>> args=["--quiet", "pubmedmcp@0.1.3"], + >>> env={"UV_PYTHON": "3.12", **os.environ}, + >>> ) + + >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + + Example with structured output enabled: + ```py + >>> with ToolCollection.from_mcp(server_parameters, trust_remote_code=True, structured_output=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + + Example with a Streamable HTTP MCP server: + ```py + >>> with ToolCollection.from_mcp({"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True) as tool_collection: + >>> agent = CodeAgent(tools=[*tool_collection.tools], add_base_tools=True, model=model) + >>> agent.run("Please find a remedy for hangover.") + ``` + """ + # Handle future warning for structured_output default value change + if structured_output is None: + warnings.warn( + "Parameter 'structured_output' was not specified. " + "Currently it defaults to False, but in version 1.25, the default will change to True. " + "To suppress this warning, explicitly set structured_output=True (new behavior) or structured_output=False (legacy behavior). " + "See documentation at https://huggingface.co/docs/smolagents/tutorials/tools#structured-output-and-output-schema-support for more details.", + FutureWarning, + stacklevel=2, + ) + structured_output = False + + try: + from mcpadapt.core import MCPAdapt + from mcpadapt.smolagents_adapter import SmolAgentsAdapter + except ImportError: + raise ImportError( + """Please install 'mcp' extra to use ToolCollection.from_mcp: `pip install 'smolagents[mcp]'`.""" + ) + if isinstance(server_parameters, dict): + transport = server_parameters.get("transport") + if transport is None: + transport = "streamable-http" + server_parameters["transport"] = transport + if transport not in {"sse", "streamable-http"}: + raise ValueError( + f"Unsupported transport: {transport}. Supported transports are 'streamable-http' and 'sse'." + ) + if not trust_remote_code: + raise ValueError( + "Loading tools from MCP requires you to acknowledge you trust the MCP server, " + "as it will execute code on your local machine: pass `trust_remote_code=True`." + ) + with MCPAdapt(server_parameters, SmolAgentsAdapter(structured_output=structured_output)) as tools: + yield cls(tools) + + +def tool(tool_function: Callable) -> Tool: + """ + Convert a function into an instance of a dynamically created Tool subclass. + + Args: + tool_function (`Callable`): Function to convert into a Tool subclass. + Should have type hints for each input and a type hint for the output. + Should also have a docstring including the description of the function + and an 'Args:' part where each argument is described. + """ + tool_json_schema = get_json_schema(tool_function)["function"] + if "return" not in tool_json_schema: + if len(tool_json_schema["parameters"]["properties"]) == 0: + tool_json_schema["return"] = {"type": "null"} + else: + raise TypeHintParsingException( + "Tool return type not found: make sure your function has a return type hint!" + ) + + class SimpleTool(Tool): + def __init__(self): + self.is_initialized = True + + # Set the class attributes + SimpleTool.name = tool_json_schema["name"] + SimpleTool.description = tool_json_schema["description"] + SimpleTool.inputs = tool_json_schema["parameters"]["properties"] + SimpleTool.output_type = tool_json_schema["return"]["type"] + + # Set output_schema if it exists in the JSON schema + if "output_schema" in tool_json_schema: + SimpleTool.output_schema = tool_json_schema["output_schema"] + elif "return" in tool_json_schema and "schema" in tool_json_schema["return"]: + SimpleTool.output_schema = tool_json_schema["return"]["schema"] + + @wraps(tool_function) + def wrapped_function(*args, **kwargs): + return tool_function(*args, **kwargs) + + # Bind the copied function to the forward method + SimpleTool.forward = staticmethod(wrapped_function) + + # Get the signature parameters of the tool function + sig = inspect.signature(tool_function) + # - Add "self" as first parameter to tool_function signature + new_sig = sig.replace( + parameters=[inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)] + list(sig.parameters.values()) + ) + # - Set the signature of the forward method + SimpleTool.forward.__signature__ = new_sig + + # Create and attach the source code of the dynamically created tool class and forward method + # - Get the source code of tool_function + tool_source = textwrap.dedent(inspect.getsource(tool_function)) + # - Remove the tool decorator and function definition line + lines = tool_source.splitlines() + tree = ast.parse(tool_source) + # - Find function definition + func_node = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef)), None) + if not func_node: + raise ValueError( + f"No function definition found in the provided source of {tool_function.__name__}. " + "Ensure the input is a standard function." + ) + # - Extract decorator lines + decorator_lines = "" + if func_node.decorator_list: + tool_decorators = [d for d in func_node.decorator_list if isinstance(d, ast.Name) and d.id == "tool"] + if len(tool_decorators) > 1: + raise ValueError( + f"Multiple @tool decorators found on function '{func_node.name}'. Only one @tool decorator is allowed." + ) + if len(tool_decorators) < len(func_node.decorator_list): + warnings.warn( + f"Function '{func_node.name}' has decorators other than @tool. " + "This may cause issues with serialization in the remote executor. See issue #1626." + ) + decorator_start = tool_decorators[0].end_lineno if tool_decorators else 0 + decorator_end = func_node.decorator_list[-1].end_lineno + decorator_lines = "\n".join(lines[decorator_start:decorator_end]) + # - Extract tool source body + body_start = func_node.body[0].lineno - 1 # AST lineno starts at 1 + tool_source_body = "\n".join(lines[body_start:]) + # - Create the forward method source, including def line and indentation + forward_method_source = f"def forward{new_sig}:\n{tool_source_body}" + # - Create the class source + indent = " " * 4 # for class method + class_source = ( + textwrap.dedent(f""" + class SimpleTool(Tool): + name: str = "{tool_json_schema["name"]}" + description: str = {json.dumps(textwrap.dedent(tool_json_schema["description"]).strip())} + inputs: dict[str, dict[str, str]] = {tool_json_schema["parameters"]["properties"]} + output_type: str = "{tool_json_schema["return"]["type"]}" + + def __init__(self): + self.is_initialized = True + + """) + + textwrap.indent(decorator_lines, indent) + + textwrap.indent(forward_method_source, indent) + ) + # - Store the source code on both class and method for inspection + SimpleTool.__source__ = class_source + SimpleTool.forward.__source__ = forward_method_source + + simple_tool = SimpleTool() + return simple_tool + + +class PipelineTool(Tool): + """ + A [`Tool`] tailored towards Transformer models. On top of the class attributes of the base class [`Tool`], you will + need to specify: + + - **model_class** (`type`) -- The class to use to load the model in this tool. + - **default_checkpoint** (`str`) -- The default checkpoint that should be used when the user doesn't specify one. + - **pre_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the + pre-processor + - **post_processor_class** (`type`, *optional*, defaults to [`transformers.AutoProcessor`]) -- The class to use to load the + post-processor (when different from the pre-processor). + + Args: + model (`str` or [`transformers.PreTrainedModel`], *optional*): + The name of the checkpoint to use for the model, or the instantiated model. If unset, will default to the + value of the class attribute `default_checkpoint`. + pre_processor (`str` or `Any`, *optional*): + The name of the checkpoint to use for the pre-processor, or the instantiated pre-processor (can be a + tokenizer, an image processor, a feature extractor or a processor). Will default to the value of `model` if + unset. + post_processor (`str` or `Any`, *optional*): + The name of the checkpoint to use for the post-processor, or the instantiated pre-processor (can be a + tokenizer, an image processor, a feature extractor or a processor). Will default to the `pre_processor` if + unset. + device (`int`, `str` or `torch.device`, *optional*): + The device on which to execute the model. Will default to any accelerator available (GPU, MPS etc...), the + CPU otherwise. + device_map (`str` or `dict`, *optional*): + If passed along, will be used to instantiate the model. + model_kwargs (`dict`, *optional*): + Any keyword argument to send to the model instantiation. + token (`str`, *optional*): + The token to use as HTTP bearer authorization for remote files. If unset, will use the token generated when + running `huggingface-cli login` (stored in `~/.huggingface`). + hub_kwargs (additional keyword arguments, *optional*): + Any additional keyword argument to send to the methods that will load the data from the Hub. + """ + + pre_processor_class = None + model_class = None + post_processor_class = None + default_checkpoint = None + description = "This is a pipeline tool" + name = "pipeline" + inputs = {"prompt": str} + output_type = str + skip_forward_signature_validation = True + + def __init__( + self, + model=None, + pre_processor=None, + post_processor=None, + device=None, + device_map=None, + model_kwargs=None, + token=None, + **hub_kwargs, + ): + if not _is_package_available("accelerate") or not _is_package_available("torch"): + raise ModuleNotFoundError( + "Please install 'transformers' extra to use a PipelineTool: `pip install 'smolagents[transformers]'`" + ) + + if model is None: + if self.default_checkpoint is None: + raise ValueError("This tool does not implement a default checkpoint, you need to pass one.") + model = self.default_checkpoint + if pre_processor is None: + pre_processor = model + + self.model = model + self.pre_processor = pre_processor + self.post_processor = post_processor + self.device = device + self.device_map = device_map + self.model_kwargs = {} if model_kwargs is None else model_kwargs + if device_map is not None: + self.model_kwargs["device_map"] = device_map + self.hub_kwargs = hub_kwargs + self.hub_kwargs["token"] = token + + super().__init__() + + def setup(self): + """ + Instantiates the `pre_processor`, `model` and `post_processor` if necessary. + """ + if isinstance(self.pre_processor, str): + if self.pre_processor_class is None: + from transformers import AutoProcessor + + self.pre_processor_class = AutoProcessor + self.pre_processor = self.pre_processor_class.from_pretrained(self.pre_processor, **self.hub_kwargs) + + if isinstance(self.model, str): + self.model = self.model_class.from_pretrained(self.model, **self.model_kwargs, **self.hub_kwargs) + + if self.post_processor is None: + self.post_processor = self.pre_processor + elif isinstance(self.post_processor, str): + if self.post_processor_class is None: + from transformers import AutoProcessor + + self.post_processor_class = AutoProcessor + self.post_processor = self.post_processor_class.from_pretrained(self.post_processor, **self.hub_kwargs) + + if self.device is None: + if self.device_map is not None: + self.device = list(self.model.hf_device_map.values())[0] + else: + from accelerate import PartialState + + self.device = PartialState().default_device + + if self.device_map is None: + self.model.to(self.device) + + super().setup() + + def encode(self, raw_inputs): + """ + Uses the `pre_processor` to prepare the inputs for the `model`. + """ + return self.pre_processor(raw_inputs) + + def forward(self, inputs): + """ + Sends the inputs through the `model`. + """ + import torch + + with torch.no_grad(): + return self.model(**inputs) + + def decode(self, outputs): + """ + Uses the `post_processor` to decode the model output. + """ + return self.post_processor(outputs) + + def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): + import torch + from accelerate.utils import send_to_device + + if not self.is_initialized: + self.setup() + + if sanitize_inputs_outputs: + args, kwargs = handle_agent_input_types(*args, **kwargs) + encoded_inputs = self.encode(*args, **kwargs) + + tensor_inputs = {k: v for k, v in encoded_inputs.items() if isinstance(v, torch.Tensor)} + non_tensor_inputs = {k: v for k, v in encoded_inputs.items() if not isinstance(v, torch.Tensor)} + + encoded_inputs = send_to_device(tensor_inputs, self.device) + outputs = self.forward({**encoded_inputs, **non_tensor_inputs}) + outputs = send_to_device(outputs, "cpu") + decoded_outputs = self.decode(outputs) + if sanitize_inputs_outputs: + decoded_outputs = handle_agent_output_types(decoded_outputs, self.output_type) + return decoded_outputs + + +def get_tools_definition_code(tools: dict[str, Tool]) -> str: + tool_codes = [] + for tool in tools.values(): + validate_tool_attributes(tool.__class__, check_imports=False) + tool_code = instance_to_source(tool, base_cls=Tool) + tool_code = tool_code.replace("from smolagents.tools import Tool", "") + tool_code += f"\n\n{tool.name} = {tool.__class__.__name__}()\n" + tool_codes.append(tool_code) + + tool_definition_code = "\n".join([f"import {module}" for module in BASE_BUILTIN_MODULES]) + tool_definition_code += textwrap.dedent( + """ + from typing import Any + + class Tool: + def __call__(self, *args, **kwargs): + return self.forward(*args, **kwargs) + + def forward(self, *args, **kwargs): + pass # to be implemented in child class + """ + ) + tool_definition_code += "\n\n".join(tool_codes) + return tool_definition_code + + +def validate_tool_arguments(tool: Tool, arguments: Any) -> None: + """Validate tool arguments against tool's input schema. + + Checks that all provided arguments match the tool's expected input types and that + all required arguments are present. Supports both dictionary arguments and single + value arguments for tools with one input parameter. + + Args: + tool (`Tool`): Tool whose input schema will be used for validation. + arguments (`Any`): Arguments to validate. Can be a dictionary mapping + argument names to values, or a single value for tools with one input. + + + Raises: + ValueError: If an argument is not in the tool's input schema, if a required + argument is missing, or if the argument value doesn't match the expected type. + TypeError: If an argument has an incorrect type that cannot be converted + (e.g., string instead of number, excluding integer to number conversion). + + Note: + - Supports type coercion from integer to number + - Handles nullable parameters when explicitly marked in the schema + - Accepts "any" type as a wildcard that matches all types + """ + if isinstance(arguments, dict): + for key, value in arguments.items(): + if key not in tool.inputs: + raise ValueError(f"Argument {key} is not in the tool's input schema") + + actual_type = _get_json_schema_type(type(value))["type"] + expected_type = tool.inputs[key]["type"] + expected_type_is_nullable = tool.inputs[key].get("nullable", False) + + # Type is valid if it matches, is "any", or is null for nullable parameters + if ( + (actual_type != expected_type if isinstance(expected_type, str) else actual_type not in expected_type) + and expected_type != "any" + and not (actual_type == "null" and expected_type_is_nullable) + ): + if actual_type == "integer" and expected_type == "number": + continue + raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'") + + for key, schema in tool.inputs.items(): + key_is_nullable = schema.get("nullable", False) + if key not in arguments and not key_is_nullable: + raise ValueError(f"Argument {key} is required") + return None + else: + expected_type = list(tool.inputs.values())[0]["type"] + if _get_json_schema_type(type(arguments))["type"] != expected_type and not expected_type == "any": + raise TypeError(f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'") + + +__all__ = [ + "AUTHORIZED_TYPES", + "Tool", + "tool", + "load_tool", + "launch_gradio_demo", + "ToolCollection", ] diff --git a/tests/test_tools.py b/tests/test_tools.py index f82a4daccd..d1d5d828ba 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,1071 +1,1071 @@ -# coding=utf-8 -# Copyright 2024 HuggingFace Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import inspect -import os -import warnings -from textwrap import dedent -from typing import Any, Literal -from unittest.mock import MagicMock, patch - -import mcp -import numpy as np -import PIL.Image -import pytest - -from smolagents.agent_types import _AGENT_TYPE_MAPPING -from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments - -from .utils.markers import require_run_all - - -class ToolTesterMixin: - def test_inputs_output(self): - assert hasattr(self.tool, "inputs") - assert hasattr(self.tool, "output_type") - - inputs = self.tool.inputs - assert isinstance(inputs, dict) - - for _, input_spec in inputs.items(): - assert "type" in input_spec - assert "description" in input_spec - assert input_spec["type"] in AUTHORIZED_TYPES - assert isinstance(input_spec["description"], str) - - output_type = self.tool.output_type - assert output_type in AUTHORIZED_TYPES - - def test_common_attributes(self): - assert hasattr(self.tool, "description") - assert hasattr(self.tool, "name") - assert hasattr(self.tool, "inputs") - assert hasattr(self.tool, "output_type") - - def test_agent_type_output(self, create_inputs): - inputs = create_inputs(self.tool.inputs) - output = self.tool(**inputs, sanitize_inputs_outputs=True) - if self.tool.output_type != "any": - agent_type = _AGENT_TYPE_MAPPING[self.tool.output_type] - assert isinstance(output, agent_type) - - @pytest.fixture - def create_inputs(self, shared_datadir): - def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: - inputs = {} - - for input_name, input_desc in tool_inputs.items(): - input_type = input_desc["type"] - - if input_type == "string": - inputs[input_name] = "Text input" - elif input_type == "image": - inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) - elif input_type == "audio": - inputs[input_name] = np.ones(3000) - else: - raise ValueError(f"Invalid type requested: {input_type}") - - return inputs - - return _create_inputs - - -class TestTool: - @pytest.mark.parametrize( - "type_value, should_raise_error, error_contains", - [ - # Valid cases - ("string", False, None), - (["string", "number"], False, None), - # Invalid cases - ("invalid_type", ValueError, "must be one of"), - (["string", "invalid_type"], ValueError, "must be one of"), - ([123, "string"], TypeError, "when type is a list, all elements must be strings"), - (123, TypeError, "must be a string or list of strings"), - ], - ) - def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): - """Test the validation of the type property in tool inputs.""" - - # Define a tool class with the test type value - def create_tool(): - class TestTool(Tool): - name = "test_tool" - description = "A tool for testing type validation" - inputs = {"text": {"type": type_value, "description": "Some input"}} - output_type = "string" - - def forward(self, text) -> str: - return text - - return TestTool() - - # Check if we expect this to raise an exception - if should_raise_error: - with pytest.raises(should_raise_error) as exc_info: - create_tool() - # Verify the error message contains expected text - assert error_contains in str(exc_info.value) - else: - # Should not raise an exception - tool = create_tool() - assert isinstance(tool, Tool) - - @pytest.mark.parametrize( - "tool_fixture, expected_output", - [ - ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), - ( - "single_input_tool", - 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', - ), - ( - "multi_input_tool", - 'def multi_input_tool(text: string, count: integer) -> object:\n """Tool with multiple inputs\n\n Args:\n text: Text input\n count: Number count\n """', - ), - ( - "multiline_description_tool", - 'def multiline_description_tool(input: string) -> string:\n """This is a tool with\n multiple lines\n in the description\n\n Args:\n input: Some input\n """', - ), - ], - ) - def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): - """Test that to_code_prompt generates properly formatted and indented output.""" - tool = request.getfixturevalue(tool_fixture) - code_prompt = tool.to_code_prompt() - assert code_prompt == expected_output - - @pytest.mark.parametrize( - "tool_fixture, expected_output", - [ - ( - "no_input_tool", - "no_input_tool: Tool with no inputs\n Takes inputs: {}\n Returns an output of type: string", - ), - ( - "single_input_tool", - "single_input_tool: Tool with one input\n Takes inputs: {'text': {'type': 'string', 'description': 'Input text'}}\n Returns an output of type: string", - ), - ( - "multi_input_tool", - "multi_input_tool: Tool with multiple inputs\n Takes inputs: {'text': {'type': 'string', 'description': 'Text input'}, 'count': {'type': 'integer', 'description': 'Number count'}}\n Returns an output of type: object", - ), - ( - "multiline_description_tool", - "multiline_description_tool: This is a tool with\nmultiple lines\nin the description\n Takes inputs: {'input': {'type': 'string', 'description': 'Some input'}}\n Returns an output of type: string", - ), - ], - ) - def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): - """Test that to_tool_calling_prompt generates properly formatted output.""" - tool = request.getfixturevalue(tool_fixture) - tool_calling_prompt = tool.to_tool_calling_prompt() - assert tool_calling_prompt == expected_output - - def test_tool_init_with_decorator(self): - @tool - def coolfunc(a: str, b: int) -> float: - """Cool function - - Args: - a: The first argument - b: The second one - """ - return b + 2, a - - assert coolfunc.output_type == "number" - - def test_tool_init_vanilla(self): - class HFModelDownloadsTool(Tool): - name = "model_download_counter" - description = """ - This is a tool that returns the most downloaded model of a given task on the Hugging Face Hub. - It returns the name of the checkpoint.""" - - inputs = { - "task": { - "type": "string", - "description": "the task category (such as text-classification, depth-estimation, etc)", - } - } - output_type = "string" - - def forward(self, task: str) -> str: - return "best model" - - tool = HFModelDownloadsTool() - assert list(tool.inputs.keys())[0] == "task" - - def test_tool_init_decorator_raises_issues(self): - with pytest.raises(Exception) as e: - - @tool - def coolfunc(a: str, b: int): - """Cool function - - Args: - a: The first argument - b: The second one - """ - return a + b - - assert coolfunc.output_type == "number" - assert "Tool return type not found" in str(e) - - with pytest.raises(Exception) as e: - - @tool - def coolfunc(a: str, b: int) -> int: - """Cool function - - Args: - a: The first argument - """ - return b + a - - assert coolfunc.output_type == "number" - assert "docstring has no description for the argument" in str(e) - - def test_saving_tool_raises_error_imports_outside_function(self, tmp_path): - with pytest.raises(Exception) as e: - import numpy as np - - @tool - def get_current_time() -> str: - """ - Gets the current time. - """ - return str(np.random.random()) - - get_current_time.save(tmp_path) - - assert "np" in str(e) - - # Also test with classic definition - with pytest.raises(Exception) as e: - - class GetCurrentTimeTool(Tool): - name = "get_current_time_tool" - description = "Gets the current time" - inputs = {} - output_type = "string" - - def forward(self): - return str(np.random.random()) - - get_current_time = GetCurrentTimeTool() - get_current_time.save(tmp_path) - - assert "np" in str(e) - - def test_tool_definition_raises_no_error_imports_in_function(self): - @tool - def get_current_time() -> str: - """ - Gets the current time. - """ - from datetime import datetime - - return str(datetime.now()) - - class GetCurrentTimeTool(Tool): - name = "get_current_time_tool" - description = "Gets the current time" - inputs = {} - output_type = "string" - - def forward(self): - from datetime import datetime - - return str(datetime.now()) - - def test_tool_to_dict_allows_no_arg_in_init(self): - """Test that a tool cannot be saved with required args in init""" - - class FailTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def __init__(self, url): - super().__init__(self) - self.url = url - - def forward(self, string_input: str) -> str: - return self.url + string_input - - fail_tool = FailTool("dummy_url") - with pytest.raises(Exception) as e: - fail_tool.to_dict() - assert "Parameters in __init__ must have default values, found required parameters" in str(e) - - class PassTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def __init__(self, url: str | None = "none"): - super().__init__(self) - self.url = url - - def forward(self, string_input: str) -> str: - return self.url + string_input - - fail_tool = PassTool() - fail_tool.to_dict() - - def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): - # Test that using imports from outside functions fails - import numpy as np - - class FailTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def useless_method(self): - self.client = np.random.random() - return "" - - def forward(self, string_input): - return self.useless_method() + string_input - - fail_tool = FailTool() - with pytest.raises(Exception) as e: - fail_tool.save(tmp_path) - assert "'np' is undefined" in str(e) - - # Test that putting these imports inside functions works - class SuccessTool(Tool): - name = "specific" - description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} - output_type = "string" - - def useless_method(self): - import numpy as np - - self.client = np.random.random() - return "" - - def forward(self, string_input): - return self.useless_method() + string_input - - success_tool = SuccessTool() - success_tool.save(tmp_path) - - def test_tool_missing_class_attributes_raises_error(self): - with pytest.raises(Exception) as e: - - class GetWeatherTool(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - - def forward(self, location: str, celsius: bool | None = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool() - assert "You must set an attribute output_type" in str(e) - - def test_tool_from_decorator_optional_args(self): - @tool - def get_weather(location: str, celsius: bool | None = False) -> str: - """ - Get weather in the next days at given location. - Secretly this tool does not care about the location, it hates the weather everywhere. - - Args: - location: the location - celsius: the temperature type - """ - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - assert "nullable" in get_weather.inputs["celsius"] - assert get_weather.inputs["celsius"]["nullable"] - assert "nullable" not in get_weather.inputs["location"] - - def test_tool_mismatching_nullable_args_raises_error(self): - with pytest.raises(Exception) as e: - - class GetWeatherTool(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - output_type = "string" - - def forward(self, location: str, celsius: bool | None = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool() - assert "Nullable" in str(e) - - with pytest.raises(Exception) as e: - - class GetWeatherTool2(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - }, - } - output_type = "string" - - def forward(self, location: str, celsius: bool = False) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool2() - assert "Nullable" in str(e) - - with pytest.raises(Exception) as e: - - class GetWeatherTool3(Tool): - name = "get_weather" - description = "Get weather in the next days at given location." - inputs = { - "location": {"type": "string", "description": "the location"}, - "celsius": { - "type": "string", - "description": "the temperature type", - "nullable": True, - }, - } - output_type = "string" - - def forward(self, location, celsius: str) -> str: - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - GetWeatherTool3() - assert "Nullable" in str(e) - - def test_tool_default_parameters_is_nullable(self): - @tool - def get_weather(location: str, celsius: bool = False) -> str: - """ - Get weather in the next days at given location. - - Args: - location: The location to get the weather for. - celsius: is the temperature given in celsius? - """ - return "The weather is UNGODLY with torrential rains and temperatures below -10°C" - - assert get_weather.inputs["celsius"]["nullable"] - - def test_tool_supports_any_none(self, tmp_path): - @tool - def get_weather(location: Any) -> None: - """ - Get weather in the next days at given location. - - Args: - location: The location to get the weather for. - """ - return - - get_weather.save(tmp_path) - assert get_weather.inputs["location"]["type"] == "any" - assert get_weather.output_type == "null" - - def test_tool_supports_array(self): - @tool - def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: - """ - Get weather in the next days at given locations. - - Args: - locations: The locations to get the weather for. - months: The months to get the weather for - """ - return - - assert get_weather.inputs["locations"]["type"] == "array" - assert get_weather.inputs["months"]["type"] == "array" - - def test_tool_supports_string_literal(self): - @tool - def get_weather(unit: Literal["celsius", "fahrenheit"] = "celsius") -> None: - """ - Get weather in the next days at given location. - - Args: - unit: The unit of temperature - """ - return - - assert get_weather.inputs["unit"]["type"] == "string" - assert get_weather.inputs["unit"]["enum"] == ["celsius", "fahrenheit"] - - def test_tool_supports_numeric_literal(self): - @tool - def get_choice(choice: Literal[1, 2, 3]) -> None: - """ - Get choice based on the provided numeric literal. - - Args: - choice: The numeric choice to be made. - """ - return - - assert get_choice.inputs["choice"]["type"] == "integer" - assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - - def test_tool_supports_nullable_literal(self): - @tool - def get_choice(choice: Literal[1, 2, 3, None]) -> None: - """ - Get choice based on the provided value. - - Args: - choice: The numeric choice to be made. - """ - return - - assert get_choice.inputs["choice"]["type"] == "integer" - assert get_choice.inputs["choice"]["nullable"] is True - assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - - def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): - @tool - def get_weather(location: Any) -> None: - """ - Get weather in the next days at given location. - And works pretty well. - - Args: - location: The location to get the weather for. - """ - return - - get_weather.save(tmp_path) - with open(os.path.join(tmp_path, "tool.py"), "r", encoding="utf-8") as f: - source_code = f.read() - compile(source_code, f.name, "exec") - - @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) - def test_to_dict_boolean_default_input(self, fixture_name, request): - """Test that boolean input parameter with default value is correctly represented in to_dict output""" - tool = request.getfixturevalue(fixture_name) - result = tool.to_dict() - # Check that the boolean default annotation is preserved - assert "flag: bool = False" in result["code"] - # Check nullable attribute is set for the parameter with default value - assert "'nullable': True" in result["code"] - - @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) - def test_to_dict_optional_input(self, fixture_name, request): - """Test that Optional/nullable input parameter is correctly represented in to_dict output""" - tool = request.getfixturevalue(fixture_name) - result = tool.to_dict() - # Check the Optional type annotation is preserved - assert "optional_text: str | None = None" in result["code"] - # Check that the input is marked as nullable in the code - assert "'nullable': True" in result["code"] - - def test_from_dict_roundtrip(self, example_tool): - # Convert to dict - tool_dict = example_tool.to_dict() - # Create from dict - recreated_tool = Tool.from_dict(tool_dict) - # Verify properties - assert recreated_tool.name == example_tool.name - assert recreated_tool.description == example_tool.description - assert recreated_tool.inputs == example_tool.inputs - assert recreated_tool.output_type == example_tool.output_type - # Verify functionality - test_input = "Hello, world!" - assert recreated_tool(test_input) == test_input.upper() - - def test_tool_from_dict_invalid(self): - # Missing code key - with pytest.raises(ValueError) as e: - Tool.from_dict({"name": "invalid_tool"}) - assert "must contain 'code' key" in str(e) - - def test_tool_decorator_preserves_original_function(self): - # Define a test function with type hints and docstring - def test_function(items: list[str]) -> str: - """Join a list of strings. - Args: - items: A list of strings to join - Returns: - The joined string - """ - return ", ".join(items) - - # Store original function signature, name, and source - original_signature = inspect.signature(test_function) - original_name = test_function.__name__ - original_docstring = test_function.__doc__ - - # Create a tool from the function - test_tool = tool(test_function) - - # Check that the original function is unchanged - assert original_signature == inspect.signature(test_function) - assert original_name == test_function.__name__ - assert original_docstring == test_function.__doc__ - - # Verify that the tool's forward method has a different signature (it has 'self') - tool_forward_sig = inspect.signature(test_tool.forward) - assert list(tool_forward_sig.parameters.keys())[0] == "self" - - # Original function should not have 'self' parameter - assert "self" not in original_signature.parameters - - def test_tool_with_union_type_return(self): - @tool - def union_type_return_tool_function(param: int) -> str | bool: - """ - Tool with output union type. - - Args: - param: Input parameter. - """ - return str(param) if param > 0 else False - - assert isinstance(union_type_return_tool_function, Tool) - assert union_type_return_tool_function.output_type == "any" - - -class TestToolDecorator: - def test_tool_decorator_source_extraction_with_multiple_decorators(self): - """Test that @tool correctly extracts source code with multiple decorators.""" - - def dummy_decorator(func): - return func - - with pytest.warns(UserWarning, match="has decorators other than @tool"): - - @tool - @dummy_decorator - def multi_decorator_tool(text: str) -> str: - """Tool with multiple decorators. - - Args: - text: Input text - """ - return text.upper() - - # Verify the tool works - assert isinstance(multi_decorator_tool, Tool) - assert multi_decorator_tool.name == "multi_decorator_tool" - assert multi_decorator_tool("hello") == "HELLO" - - # Verify the source code extraction is correct - forward_source = multi_decorator_tool.forward.__source__ - assert "def forward(self, text: str) -> str:" in forward_source - assert "return text.upper()" in forward_source - # Should not contain decorator lines - assert "@tool" not in forward_source - assert "@dummy_decorator" not in forward_source - # Should not contain definition line - assert "def multi_decorator_tool" not in forward_source - - def test_tool_decorator_source_extraction_with_multiline_signature(self): - """Test that @tool correctly extracts source code with multiline function signatures.""" - - with warnings.catch_warnings(): - warnings.simplefilter("error") - - @tool - def multiline_signature_tool( - text: str, - count: int = 1, - uppercase: bool = False, - multiline_parameter_1: int = 1_000, - multiline_parameter_2: int = 2_000, - ) -> str: - """Tool with multiline signature. - - Args: - text: Input text - count: Number of repetitions - uppercase: Whether to convert to uppercase - multiline_parameter_1: Dummy parameter - multiline_parameter_2: Dummy parameter - """ - result = text * count - return result.upper() if uppercase else result - - # Verify the tool works - assert isinstance(multiline_signature_tool, Tool) - assert multiline_signature_tool.name == "multiline_signature_tool" - assert multiline_signature_tool("hello", 2, True) == "HELLOHELLO" - - # Verify the source code extraction is correct - forward_source = multiline_signature_tool.forward.__source__ - assert ( - "def forward(self, text: str, count: int=1, uppercase: bool=False, multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" - in forward_source - or "def forward(self, text: str, count: int = 1, uppercase: bool = False, multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" - in forward_source - ) - assert "result = text * count" in forward_source - assert "return result.upper() if uppercase else result" in forward_source - # Should not contain the original multiline function definition - assert "def multiline_signature_tool(" not in forward_source - # Should not contain leftover lines from the original multiline function definition - assert " count: int = 1," not in forward_source - assert " count: int=1," not in forward_source - - def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): - """Test that @tool works with both multiple decorators and multiline signatures.""" - - def dummy_decorator_1(func): - return func - - def dummy_decorator_2(func): - return func - - with pytest.warns(UserWarning, match="has decorators other than @tool"): - - @tool - @dummy_decorator_1 - @dummy_decorator_2 - def complex_tool( - text: str, - multiplier: int = 2, - separator: str = " ", - multiline_parameter_1: int = 1_000, - multiline_parameter_2: int = 2_000, - ) -> str: - """Complex tool with multiple decorators and multiline signature. - - Args: - text: Input text - multiplier: How many times to repeat - separator: What to use between repetitions - multiline_parameter_1: Dummy parameter - multiline_parameter_2: Dummy parameter - """ - parts = [text] * multiplier - return separator.join(parts) - - # Verify the tool works - assert isinstance(complex_tool, Tool) - assert complex_tool.name == "complex_tool" - assert complex_tool("hello", 3, "-") == "hello-hello-hello" - - # Verify the source code extraction is correct - forward_source = complex_tool.forward.__source__ - assert ( - "def forward(self, text: str, multiplier: int=2, separator: str=' ', multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" - in forward_source - or "def forward(self, text: str, multiplier: int = 2, separator: str = ' ', multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" - in forward_source - ) - assert "parts = [text] * multiplier" in forward_source - assert "return separator.join(parts)" in forward_source - # Should not contain any decorator lines - assert "@tool" not in forward_source - assert "@dummy_decorator_1" not in forward_source - assert "@dummy_decorator_2" not in forward_source - # Should not contain leftover lines from the original multiline function definition - assert " multiplier: int = 2," not in forward_source - assert " multiplier: int=2," not in forward_source - - -@pytest.fixture -def mock_server_parameters(): - return MagicMock() - - -@pytest.fixture -def mock_mcp_adapt(): - with patch("mcpadapt.core.MCPAdapt") as mock: - mock.return_value.__enter__.return_value = ["tool1", "tool2"] - mock.return_value.__exit__.return_value = None - yield mock - - -@pytest.fixture -def mock_smolagents_adapter(): - with patch("mcpadapt.smolagents_adapter.SmolAgentsAdapter") as mock: - yield mock - - -# Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior -@pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") -class TestToolCollection: - def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): - with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: - assert isinstance(tool_collection, ToolCollection) - assert len(tool_collection.tools) == 2 - assert "tool1" in tool_collection.tools - assert "tool2" in tool_collection.tools - - @require_run_all - def test_integration_from_mcp(self): - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server") - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run() - """).strip() - - mcp_server_params = mcp.StdioServerParameters( - command="python", - args=["-c", mcp_server_script], - ) - - with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - - def test_integration_from_mcp_with_streamable_http(self): - import subprocess - import time - - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run(transport="streamable-http") - """).strip() - - # start the SSE mcp server in a subprocess - server_process = subprocess.Popen( - ["python", "-c", mcp_server_script], - ) - - # wait for the server to start - time.sleep(1) - - try: - with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True - ) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - finally: - # clean up the process when test is done - server_process.kill() - server_process.wait() - - def test_integration_from_mcp_with_sse(self): - import subprocess - import time - - # define the most simple mcp server with one tool that echoes the input text - mcp_server_script = dedent("""\ - from mcp.server.fastmcp import FastMCP - - mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) - - @mcp.tool() - def echo_tool(text: str) -> str: - return text - - mcp.run("sse") - """).strip() - - # start the SSE mcp server in a subprocess - server_process = subprocess.Popen( - ["python", "-c", mcp_server_script], - ) - - # wait for the server to start - time.sleep(1) - - try: - with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True - ) as tool_collection: - assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" - finally: - # clean up the process when test is done - server_process.kill() - server_process.wait() - - -@pytest.mark.parametrize("tool_fixture_name", ["boolean_default_tool_class"]) -def test_launch_gradio_demo_does_not_raise(tool_fixture_name, request): - tool = request.getfixturevalue(tool_fixture_name) - with patch("gradio.Interface.launch") as mock_launch: - launch_gradio_demo(tool) - assert mock_launch.call_count == 1 - - -@pytest.mark.parametrize( - "tool_input_type, expected_input, expects_error", - [ - (bool, True, False), - (str, "b", False), - (int, 1, False), - (float, 1, False), - (list, ["a", "b"], False), - (list[str], ["a", "b"], False), - (dict[str, str], {"a": "b"}, False), - (dict[str, str], "b", True), - (bool, "b", True), - (str | int, "a", False), - (str | int, 1, False), - (str | int, None, True), - (str | int, True, True), - ], -) -def test_validate_tool_arguments(tool_input_type, expected_input, expects_error): - @tool - def test_tool(argument_a: tool_input_type) -> str: - """Fake tool - - Args: - argument_a: The input - """ - return argument_a - - if expects_error: - with pytest.raises((ValueError, TypeError)): - validate_tool_arguments(test_tool, {"argument_a": expected_input}) - - else: - # Should not raise any exception - validate_tool_arguments(test_tool, {"argument_a": expected_input}) - - -@pytest.mark.parametrize( - "scenario, type_hint, default, input_value, expected_error_message", - [ - # Required parameters (no default) - # - Valid input - ("required_unsupported_none", str, ..., "text", None), - # - None not allowed - ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), - # - Missing required parameter is not allowed - ("required_unsupported_none", str, ..., ..., "Argument param is required"), - # - # Required parameters but supports None - # - Valid input - ("required_supported_none", str | None, ..., "text", None), - # - None allowed - ("required_supported_none", str | None, ..., None, None), - # - Missing required parameter is not allowed - # TODO: Fix this test case: property is marked as nullable because it can be None, but it can't be missing because it is required - # ("required_supported_none", str | None, ..., ..., "Argument param is required"), - pytest.param( - "required_supported_none", - str | None, - ..., - ..., - "Argument param is required", - marks=pytest.mark.skip(reason="TODO: Fix this test case"), - ), - # - # Optional parameters (has default, doesn't support None) - # - Valid input - ("optional_unsupported_none", str, "default", "text", None), - # - None not allowed - # TODO: Fix this test case: property is marked as nullable because it has a default value, but it can't be None - # ("optional_unsupported_none", str, "default", None, "Argument param has type 'null' but should be 'string'"), - pytest.param( - "optional_unsupported_none", - str, - "default", - None, - "Argument param has type 'null' but should be 'string'", - marks=pytest.mark.skip(reason="TODO: Fix this test case"), - ), - # - Missing optional parameter is allowed - ("optional_unsupported_none", str, "default", ..., None), - # - # Optional and supports None parameters with string default - # - Valid input - ("optional_supported_none_str_default", str | None, "default", "text", None), - # - None allowed - ("optional_supported_none_str_default", str | None, "default", None, None), - # - Missing optional parameter is allowed - ("optional_supported_none_str_default", str | None, "default", ..., None), - # - # Optional and supports None parameters with None default - # - Valid input - ("optional_supported_none_none_default", str | None, None, "text", None), - # - None allowed - ("optional_supported_none_none_default", str | None, None, None, None), - # - Missing optional parameter is allowed - ("optional_supported_none_none_default", str | None, None, ..., None), - ], -) -def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): - """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" - - # Create a tool with the appropriate signature - if default is ...: # Using Ellipsis to indicate no default value - - @tool - def test_tool(param: type_hint) -> str: - """Test tool. - - Args: - param: Input param - """ - return str(param) if param is not None else "NULL" - else: - - @tool - def test_tool(param: type_hint = default) -> str: - """Test tool. - - Args: - param: Input param. - """ - return str(param) if param is not None else "NULL" - - # Test with the input dictionary - input_dict = {"param": input_value} if input_value is not ... else {} - - if expected_error_message: - with pytest.raises((ValueError, TypeError), match=expected_error_message): - validate_tool_arguments(test_tool, input_dict) - else: - # Should not raise any exception - validate_tool_arguments(test_tool, input_dict) +# coding=utf-8 +# Copyright 2024 HuggingFace Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import inspect +import os +import warnings +from textwrap import dedent +from typing import Any, Literal +from unittest.mock import MagicMock, patch + +import mcp +import numpy as np +import PIL.Image +import pytest + +from smolagents.agent_types import _AGENT_TYPE_MAPPING +from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments + +from .utils.markers import require_run_all + + +class ToolTesterMixin: + def test_inputs_output(self): + assert hasattr(self.tool, "inputs") + assert hasattr(self.tool, "output_type") + + inputs = self.tool.inputs + assert isinstance(inputs, dict) + + for _, input_spec in inputs.items(): + assert "type" in input_spec + assert "description" in input_spec + assert input_spec["type"] in AUTHORIZED_TYPES + assert isinstance(input_spec["description"], str) + + output_type = self.tool.output_type + assert output_type in AUTHORIZED_TYPES + + def test_common_attributes(self): + assert hasattr(self.tool, "description") + assert hasattr(self.tool, "name") + assert hasattr(self.tool, "inputs") + assert hasattr(self.tool, "output_type") + + def test_agent_type_output(self, create_inputs): + inputs = create_inputs(self.tool.inputs) + output = self.tool(**inputs, sanitize_inputs_outputs=True) + if self.tool.output_type != "any": + agent_type = _AGENT_TYPE_MAPPING[self.tool.output_type] + assert isinstance(output, agent_type) + + @pytest.fixture + def create_inputs(self, shared_datadir): + def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: + inputs = {} + + for input_name, input_desc in tool_inputs.items(): + input_type = input_desc["type"] + + if input_type == "string": + inputs[input_name] = "Text input" + elif input_type == "image": + inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) + elif input_type == "audio": + inputs[input_name] = np.ones(3000) + else: + raise ValueError(f"Invalid type requested: {input_type}") + + return inputs + + return _create_inputs + + +class TestTool: + @pytest.mark.parametrize( + "type_value, should_raise_error, error_contains", + [ + # Valid cases + ("string", False, None), + (["string", "number"], False, None), + # Invalid cases + ("invalid_type", ValueError, "must be one of"), + (["string", "invalid_type"], ValueError, "must be one of"), + ([123, "string"], TypeError, "when type is a list, all elements must be strings"), + (123, TypeError, "must be a string or list of strings"), + ], + ) + def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): + """Test the validation of the type property in tool inputs.""" + + # Define a tool class with the test type value + def create_tool(): + class TestTool(Tool): + name = "test_tool" + description = "A tool for testing type validation" + inputs = {"text": {"type": type_value, "description": "Some input"}} + output_type = "string" + + def forward(self, text) -> str: + return text + + return TestTool() + + # Check if we expect this to raise an exception + if should_raise_error: + with pytest.raises(should_raise_error) as exc_info: + create_tool() + # Verify the error message contains expected text + assert error_contains in str(exc_info.value) + else: + # Should not raise an exception + tool = create_tool() + assert isinstance(tool, Tool) + + @pytest.mark.parametrize( + "tool_fixture, expected_output", + [ + ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), + ( + "single_input_tool", + 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', + ), + ( + "multi_input_tool", + 'def multi_input_tool(text: string, count: integer) -> object:\n """Tool with multiple inputs\n\n Args:\n text: Text input\n count: Number count\n """', + ), + ( + "multiline_description_tool", + 'def multiline_description_tool(input: string) -> string:\n """This is a tool with\n multiple lines\n in the description\n\n Args:\n input: Some input\n """', + ), + ], + ) + def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): + """Test that to_code_prompt generates properly formatted and indented output.""" + tool = request.getfixturevalue(tool_fixture) + code_prompt = tool.to_code_prompt() + assert code_prompt == expected_output + + @pytest.mark.parametrize( + "tool_fixture, expected_output", + [ + ( + "no_input_tool", + "no_input_tool: Tool with no inputs\n Takes inputs: {}\n Returns an output of type: string", + ), + ( + "single_input_tool", + "single_input_tool: Tool with one input\n Takes inputs: {'text': {'type': 'string', 'description': 'Input text'}}\n Returns an output of type: string", + ), + ( + "multi_input_tool", + "multi_input_tool: Tool with multiple inputs\n Takes inputs: {'text': {'type': 'string', 'description': 'Text input'}, 'count': {'type': 'integer', 'description': 'Number count'}}\n Returns an output of type: object", + ), + ( + "multiline_description_tool", + "multiline_description_tool: This is a tool with\nmultiple lines\nin the description\n Takes inputs: {'input': {'type': 'string', 'description': 'Some input'}}\n Returns an output of type: string", + ), + ], + ) + def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): + """Test that to_tool_calling_prompt generates properly formatted output.""" + tool = request.getfixturevalue(tool_fixture) + tool_calling_prompt = tool.to_tool_calling_prompt() + assert tool_calling_prompt == expected_output + + def test_tool_init_with_decorator(self): + @tool + def coolfunc(a: str, b: int) -> float: + """Cool function + + Args: + a: The first argument + b: The second one + """ + return b + 2, a + + assert coolfunc.output_type == "number" + + def test_tool_init_vanilla(self): + class HFModelDownloadsTool(Tool): + name = "model_download_counter" + description = """ + This is a tool that returns the most downloaded model of a given task on the Hugging Face Hub. + It returns the name of the checkpoint.""" + + inputs = { + "task": { + "type": "string", + "description": "the task category (such as text-classification, depth-estimation, etc)", + } + } + output_type = "string" + + def forward(self, task: str) -> str: + return "best model" + + tool = HFModelDownloadsTool() + assert list(tool.inputs.keys())[0] == "task" + + def test_tool_init_decorator_raises_issues(self): + with pytest.raises(Exception) as e: + + @tool + def coolfunc(a: str, b: int): + """Cool function + + Args: + a: The first argument + b: The second one + """ + return a + b + + assert coolfunc.output_type == "number" + assert "Tool return type not found" in str(e) + + with pytest.raises(Exception) as e: + + @tool + def coolfunc(a: str, b: int) -> int: + """Cool function + + Args: + a: The first argument + """ + return b + a + + assert coolfunc.output_type == "number" + assert "docstring has no description for the argument" in str(e) + + def test_saving_tool_raises_error_imports_outside_function(self, tmp_path): + with pytest.raises(Exception) as e: + import numpy as np + + @tool + def get_current_time() -> str: + """ + Gets the current time. + """ + return str(np.random.random()) + + get_current_time.save(tmp_path) + + assert "np" in str(e) + + # Also test with classic definition + with pytest.raises(Exception) as e: + + class GetCurrentTimeTool(Tool): + name = "get_current_time_tool" + description = "Gets the current time" + inputs = {} + output_type = "string" + + def forward(self): + return str(np.random.random()) + + get_current_time = GetCurrentTimeTool() + get_current_time.save(tmp_path) + + assert "np" in str(e) + + def test_tool_definition_raises_no_error_imports_in_function(self): + @tool + def get_current_time() -> str: + """ + Gets the current time. + """ + from datetime import datetime + + return str(datetime.now()) + + class GetCurrentTimeTool(Tool): + name = "get_current_time_tool" + description = "Gets the current time" + inputs = {} + output_type = "string" + + def forward(self): + from datetime import datetime + + return str(datetime.now()) + + def test_tool_to_dict_allows_no_arg_in_init(self): + """Test that a tool cannot be saved with required args in init""" + + class FailTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def __init__(self, url): + super().__init__(self) + self.url = url + + def forward(self, string_input: str) -> str: + return self.url + string_input + + fail_tool = FailTool("dummy_url") + with pytest.raises(Exception) as e: + fail_tool.to_dict() + assert "Parameters in __init__ must have default values, found required parameters" in str(e) + + class PassTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def __init__(self, url: str | None = "none"): + super().__init__(self) + self.url = url + + def forward(self, string_input: str) -> str: + return self.url + string_input + + fail_tool = PassTool() + fail_tool.to_dict() + + def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): + # Test that using imports from outside functions fails + import numpy as np + + class FailTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def useless_method(self): + self.client = np.random.random() + return "" + + def forward(self, string_input): + return self.useless_method() + string_input + + fail_tool = FailTool() + with pytest.raises(Exception) as e: + fail_tool.save(tmp_path) + assert "'np' is undefined" in str(e) + + # Test that putting these imports inside functions works + class SuccessTool(Tool): + name = "specific" + description = "test description" + inputs = {"string_input": {"type": "string", "description": "input description"}} + output_type = "string" + + def useless_method(self): + import numpy as np + + self.client = np.random.random() + return "" + + def forward(self, string_input): + return self.useless_method() + string_input + + success_tool = SuccessTool() + success_tool.save(tmp_path) + + def test_tool_missing_class_attributes_raises_error(self): + with pytest.raises(Exception) as e: + + class GetWeatherTool(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + + def forward(self, location: str, celsius: bool | None = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool() + assert "You must set an attribute output_type" in str(e) + + def test_tool_from_decorator_optional_args(self): + @tool + def get_weather(location: str, celsius: bool | None = False) -> str: + """ + Get weather in the next days at given location. + Secretly this tool does not care about the location, it hates the weather everywhere. + + Args: + location: the location + celsius: the temperature type + """ + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + assert "nullable" in get_weather.inputs["celsius"] + assert get_weather.inputs["celsius"]["nullable"] + assert "nullable" not in get_weather.inputs["location"] + + def test_tool_mismatching_nullable_args_raises_error(self): + with pytest.raises(Exception) as e: + + class GetWeatherTool(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + output_type = "string" + + def forward(self, location: str, celsius: bool | None = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool() + assert "Nullable" in str(e) + + with pytest.raises(Exception) as e: + + class GetWeatherTool2(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + }, + } + output_type = "string" + + def forward(self, location: str, celsius: bool = False) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool2() + assert "Nullable" in str(e) + + with pytest.raises(Exception) as e: + + class GetWeatherTool3(Tool): + name = "get_weather" + description = "Get weather in the next days at given location." + inputs = { + "location": {"type": "string", "description": "the location"}, + "celsius": { + "type": "string", + "description": "the temperature type", + "nullable": True, + }, + } + output_type = "string" + + def forward(self, location, celsius: str) -> str: + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + GetWeatherTool3() + assert "Nullable" in str(e) + + def test_tool_default_parameters_is_nullable(self): + @tool + def get_weather(location: str, celsius: bool = False) -> str: + """ + Get weather in the next days at given location. + + Args: + location: The location to get the weather for. + celsius: is the temperature given in celsius? + """ + return "The weather is UNGODLY with torrential rains and temperatures below -10°C" + + assert get_weather.inputs["celsius"]["nullable"] + + def test_tool_supports_any_none(self, tmp_path): + @tool + def get_weather(location: Any) -> None: + """ + Get weather in the next days at given location. + + Args: + location: The location to get the weather for. + """ + return + + get_weather.save(tmp_path) + assert get_weather.inputs["location"]["type"] == "any" + assert get_weather.output_type == "null" + + def test_tool_supports_array(self): + @tool + def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: + """ + Get weather in the next days at given locations. + + Args: + locations: The locations to get the weather for. + months: The months to get the weather for + """ + return + + assert get_weather.inputs["locations"]["type"] == "array" + assert get_weather.inputs["months"]["type"] == "array" + + def test_tool_supports_string_literal(self): + @tool + def get_weather(unit: Literal["celsius", "fahrenheit"] = "celsius") -> None: + """ + Get weather in the next days at given location. + + Args: + unit: The unit of temperature + """ + return + + assert get_weather.inputs["unit"]["type"] == "string" + assert get_weather.inputs["unit"]["enum"] == ["celsius", "fahrenheit"] + + def test_tool_supports_numeric_literal(self): + @tool + def get_choice(choice: Literal[1, 2, 3]) -> None: + """ + Get choice based on the provided numeric literal. + + Args: + choice: The numeric choice to be made. + """ + return + + assert get_choice.inputs["choice"]["type"] == "integer" + assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] + + def test_tool_supports_nullable_literal(self): + @tool + def get_choice(choice: Literal[1, 2, 3, None]) -> None: + """ + Get choice based on the provided value. + + Args: + choice: The numeric choice to be made. + """ + return + + assert get_choice.inputs["choice"]["type"] == "integer" + assert get_choice.inputs["choice"]["nullable"] is True + assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] + + def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): + @tool + def get_weather(location: Any) -> None: + """ + Get weather in the next days at given location. + And works pretty well. + + Args: + location: The location to get the weather for. + """ + return + + get_weather.save(tmp_path) + with open(os.path.join(tmp_path, "tool.py"), "r", encoding="utf-8") as f: + source_code = f.read() + compile(source_code, f.name, "exec") + + @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) + def test_to_dict_boolean_default_input(self, fixture_name, request): + """Test that boolean input parameter with default value is correctly represented in to_dict output""" + tool = request.getfixturevalue(fixture_name) + result = tool.to_dict() + # Check that the boolean default annotation is preserved + assert "flag: bool = False" in result["code"] + # Check nullable attribute is set for the parameter with default value + assert "'nullable': True" in result["code"] + + @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) + def test_to_dict_optional_input(self, fixture_name, request): + """Test that Optional/nullable input parameter is correctly represented in to_dict output""" + tool = request.getfixturevalue(fixture_name) + result = tool.to_dict() + # Check the Optional type annotation is preserved + assert "optional_text: str | None = None" in result["code"] + # Check that the input is marked as nullable in the code + assert "'nullable': True" in result["code"] + + def test_from_dict_roundtrip(self, example_tool): + # Convert to dict + tool_dict = example_tool.to_dict() + # Create from dict + recreated_tool = Tool.from_dict(tool_dict) + # Verify properties + assert recreated_tool.name == example_tool.name + assert recreated_tool.description == example_tool.description + assert recreated_tool.inputs == example_tool.inputs + assert recreated_tool.output_type == example_tool.output_type + # Verify functionality + test_input = "Hello, world!" + assert recreated_tool(test_input) == test_input.upper() + + def test_tool_from_dict_invalid(self): + # Missing code key + with pytest.raises(ValueError) as e: + Tool.from_dict({"name": "invalid_tool"}) + assert "must contain 'code' key" in str(e) + + def test_tool_decorator_preserves_original_function(self): + # Define a test function with type hints and docstring + def test_function(items: list[str]) -> str: + """Join a list of strings. + Args: + items: A list of strings to join + Returns: + The joined string + """ + return ", ".join(items) + + # Store original function signature, name, and source + original_signature = inspect.signature(test_function) + original_name = test_function.__name__ + original_docstring = test_function.__doc__ + + # Create a tool from the function + test_tool = tool(test_function) + + # Check that the original function is unchanged + assert original_signature == inspect.signature(test_function) + assert original_name == test_function.__name__ + assert original_docstring == test_function.__doc__ + + # Verify that the tool's forward method has a different signature (it has 'self') + tool_forward_sig = inspect.signature(test_tool.forward) + assert list(tool_forward_sig.parameters.keys())[0] == "self" + + # Original function should not have 'self' parameter + assert "self" not in original_signature.parameters + + def test_tool_with_union_type_return(self): + @tool + def union_type_return_tool_function(param: int) -> str | bool: + """ + Tool with output union type. + + Args: + param: Input parameter. + """ + return str(param) if param > 0 else False + + assert isinstance(union_type_return_tool_function, Tool) + assert union_type_return_tool_function.output_type == "any" + + +class TestToolDecorator: + def test_tool_decorator_source_extraction_with_multiple_decorators(self): + """Test that @tool correctly extracts source code with multiple decorators.""" + + def dummy_decorator(func): + return func + + with pytest.warns(UserWarning, match="has decorators other than @tool"): + + @tool + @dummy_decorator + def multi_decorator_tool(text: str) -> str: + """Tool with multiple decorators. + + Args: + text: Input text + """ + return text.upper() + + # Verify the tool works + assert isinstance(multi_decorator_tool, Tool) + assert multi_decorator_tool.name == "multi_decorator_tool" + assert multi_decorator_tool("hello") == "HELLO" + + # Verify the source code extraction is correct + forward_source = multi_decorator_tool.forward.__source__ + assert "def forward(self, text: str) -> str:" in forward_source + assert "return text.upper()" in forward_source + # Should not contain decorator lines + assert "@tool" not in forward_source + assert "@dummy_decorator" not in forward_source + # Should not contain definition line + assert "def multi_decorator_tool" not in forward_source + + def test_tool_decorator_source_extraction_with_multiline_signature(self): + """Test that @tool correctly extracts source code with multiline function signatures.""" + + with warnings.catch_warnings(): + warnings.simplefilter("error") + + @tool + def multiline_signature_tool( + text: str, + count: int = 1, + uppercase: bool = False, + multiline_parameter_1: int = 1_000, + multiline_parameter_2: int = 2_000, + ) -> str: + """Tool with multiline signature. + + Args: + text: Input text + count: Number of repetitions + uppercase: Whether to convert to uppercase + multiline_parameter_1: Dummy parameter + multiline_parameter_2: Dummy parameter + """ + result = text * count + return result.upper() if uppercase else result + + # Verify the tool works + assert isinstance(multiline_signature_tool, Tool) + assert multiline_signature_tool.name == "multiline_signature_tool" + assert multiline_signature_tool("hello", 2, True) == "HELLOHELLO" + + # Verify the source code extraction is correct + forward_source = multiline_signature_tool.forward.__source__ + assert ( + "def forward(self, text: str, count: int=1, uppercase: bool=False, multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" + in forward_source + or "def forward(self, text: str, count: int = 1, uppercase: bool = False, multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" + in forward_source + ) + assert "result = text * count" in forward_source + assert "return result.upper() if uppercase else result" in forward_source + # Should not contain the original multiline function definition + assert "def multiline_signature_tool(" not in forward_source + # Should not contain leftover lines from the original multiline function definition + assert " count: int = 1," not in forward_source + assert " count: int=1," not in forward_source + + def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): + """Test that @tool works with both multiple decorators and multiline signatures.""" + + def dummy_decorator_1(func): + return func + + def dummy_decorator_2(func): + return func + + with pytest.warns(UserWarning, match="has decorators other than @tool"): + + @tool + @dummy_decorator_1 + @dummy_decorator_2 + def complex_tool( + text: str, + multiplier: int = 2, + separator: str = " ", + multiline_parameter_1: int = 1_000, + multiline_parameter_2: int = 2_000, + ) -> str: + """Complex tool with multiple decorators and multiline signature. + + Args: + text: Input text + multiplier: How many times to repeat + separator: What to use between repetitions + multiline_parameter_1: Dummy parameter + multiline_parameter_2: Dummy parameter + """ + parts = [text] * multiplier + return separator.join(parts) + + # Verify the tool works + assert isinstance(complex_tool, Tool) + assert complex_tool.name == "complex_tool" + assert complex_tool("hello", 3, "-") == "hello-hello-hello" + + # Verify the source code extraction is correct + forward_source = complex_tool.forward.__source__ + assert ( + "def forward(self, text: str, multiplier: int=2, separator: str=' ', multiline_parameter_1: int=1000, multiline_parameter_2: int=2000) -> str:" + in forward_source + or "def forward(self, text: str, multiplier: int = 2, separator: str = ' ', multiline_parameter_1: int = 1000, multiline_parameter_2: int = 2000) -> str:" + in forward_source + ) + assert "parts = [text] * multiplier" in forward_source + assert "return separator.join(parts)" in forward_source + # Should not contain any decorator lines + assert "@tool" not in forward_source + assert "@dummy_decorator_1" not in forward_source + assert "@dummy_decorator_2" not in forward_source + # Should not contain leftover lines from the original multiline function definition + assert " multiplier: int = 2," not in forward_source + assert " multiplier: int=2," not in forward_source + + +@pytest.fixture +def mock_server_parameters(): + return MagicMock() + + +@pytest.fixture +def mock_mcp_adapt(): + with patch("mcpadapt.core.MCPAdapt") as mock: + mock.return_value.__enter__.return_value = ["tool1", "tool2"] + mock.return_value.__exit__.return_value = None + yield mock + + +@pytest.fixture +def mock_smolagents_adapter(): + with patch("mcpadapt.smolagents_adapter.SmolAgentsAdapter") as mock: + yield mock + + +# Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior +@pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") +class TestToolCollection: + def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): + with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: + assert isinstance(tool_collection, ToolCollection) + assert len(tool_collection.tools) == 2 + assert "tool1" in tool_collection.tools + assert "tool2" in tool_collection.tools + + @require_run_all + def test_integration_from_mcp(self): + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server") + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run() + """).strip() + + mcp_server_params = mcp.StdioServerParameters( + command="python", + args=["-c", mcp_server_script], + ) + + with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + + def test_integration_from_mcp_with_streamable_http(self): + import subprocess + import time + + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run(transport="streamable-http") + """).strip() + + # start the SSE mcp server in a subprocess + server_process = subprocess.Popen( + ["python", "-c", mcp_server_script], + ) + + # wait for the server to start + time.sleep(1) + + try: + with ToolCollection.from_mcp( + {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True + ) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + finally: + # clean up the process when test is done + server_process.kill() + server_process.wait() + + def test_integration_from_mcp_with_sse(self): + import subprocess + import time + + # define the most simple mcp server with one tool that echoes the input text + mcp_server_script = dedent("""\ + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("Echo Server", host="127.0.0.1", port=8000) + + @mcp.tool() + def echo_tool(text: str) -> str: + return text + + mcp.run("sse") + """).strip() + + # start the SSE mcp server in a subprocess + server_process = subprocess.Popen( + ["python", "-c", mcp_server_script], + ) + + # wait for the server to start + time.sleep(1) + + try: + with ToolCollection.from_mcp( + {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True + ) as tool_collection: + assert len(tool_collection.tools) == 1, "Expected 1 tool" + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + finally: + # clean up the process when test is done + server_process.kill() + server_process.wait() + + +@pytest.mark.parametrize("tool_fixture_name", ["boolean_default_tool_class"]) +def test_launch_gradio_demo_does_not_raise(tool_fixture_name, request): + tool = request.getfixturevalue(tool_fixture_name) + with patch("gradio.Interface.launch") as mock_launch: + launch_gradio_demo(tool) + assert mock_launch.call_count == 1 + + +@pytest.mark.parametrize( + "tool_input_type, expected_input, expects_error", + [ + (bool, True, False), + (str, "b", False), + (int, 1, False), + (float, 1, False), + (list, ["a", "b"], False), + (list[str], ["a", "b"], False), + (dict[str, str], {"a": "b"}, False), + (dict[str, str], "b", True), + (bool, "b", True), + (str | int, "a", False), + (str | int, 1, False), + (str | int, None, True), + (str | int, True, True), + ], +) +def test_validate_tool_arguments(tool_input_type, expected_input, expects_error): + @tool + def test_tool(argument_a: tool_input_type) -> str: + """Fake tool + + Args: + argument_a: The input + """ + return argument_a + + if expects_error: + with pytest.raises((ValueError, TypeError)): + validate_tool_arguments(test_tool, {"argument_a": expected_input}) + + else: + # Should not raise any exception + validate_tool_arguments(test_tool, {"argument_a": expected_input}) + + +@pytest.mark.parametrize( + "scenario, type_hint, default, input_value, expected_error_message", + [ + # Required parameters (no default) + # - Valid input + ("required_unsupported_none", str, ..., "text", None), + # - None not allowed + ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), + # - Missing required parameter is not allowed + ("required_unsupported_none", str, ..., ..., "Argument param is required"), + # + # Required parameters but supports None + # - Valid input + ("required_supported_none", str | None, ..., "text", None), + # - None allowed + ("required_supported_none", str | None, ..., None, None), + # - Missing required parameter is not allowed + # TODO: Fix this test case: property is marked as nullable because it can be None, but it can't be missing because it is required + # ("required_supported_none", str | None, ..., ..., "Argument param is required"), + pytest.param( + "required_supported_none", + str | None, + ..., + ..., + "Argument param is required", + marks=pytest.mark.skip(reason="TODO: Fix this test case"), + ), + # + # Optional parameters (has default, doesn't support None) + # - Valid input + ("optional_unsupported_none", str, "default", "text", None), + # - None not allowed + # TODO: Fix this test case: property is marked as nullable because it has a default value, but it can't be None + # ("optional_unsupported_none", str, "default", None, "Argument param has type 'null' but should be 'string'"), + pytest.param( + "optional_unsupported_none", + str, + "default", + None, + "Argument param has type 'null' but should be 'string'", + marks=pytest.mark.skip(reason="TODO: Fix this test case"), + ), + # - Missing optional parameter is allowed + ("optional_unsupported_none", str, "default", ..., None), + # + # Optional and supports None parameters with string default + # - Valid input + ("optional_supported_none_str_default", str | None, "default", "text", None), + # - None allowed + ("optional_supported_none_str_default", str | None, "default", None, None), + # - Missing optional parameter is allowed + ("optional_supported_none_str_default", str | None, "default", ..., None), + # + # Optional and supports None parameters with None default + # - Valid input + ("optional_supported_none_none_default", str | None, None, "text", None), + # - None allowed + ("optional_supported_none_none_default", str | None, None, None, None), + # - Missing optional parameter is allowed + ("optional_supported_none_none_default", str | None, None, ..., None), + ], +) +def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): + """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" + + # Create a tool with the appropriate signature + if default is ...: # Using Ellipsis to indicate no default value + + @tool + def test_tool(param: type_hint) -> str: + """Test tool. + + Args: + param: Input param + """ + return str(param) if param is not None else "NULL" + else: + + @tool + def test_tool(param: type_hint = default) -> str: + """Test tool. + + Args: + param: Input param. + """ + return str(param) if param is not None else "NULL" + + # Test with the input dictionary + input_dict = {"param": input_value} if input_value is not ... else {} + + if expected_error_message: + with pytest.raises((ValueError, TypeError), match=expected_error_message): + validate_tool_arguments(test_tool, input_dict) + else: + # Should not raise any exception + validate_tool_arguments(test_tool, input_dict) def test_from_hub_pops_hub_download_options(tmp_path): """Regression for #2928: Hub download options must not reach the tool constructor. @@ -1116,4 +1116,4 @@ def forward(self, value: int) -> int: # ...but not to the tool constructor, which only received its own kwarg. assert restored.suffix == "suffix-value" - assert restored(7) == 7 + assert restored(7) == 7 From c802e64e66ff0c4be5aabd594b4143bdcd86f148 Mon Sep 17 00:00:00 2001 From: TINGyu123644 <2663180454@qq.com> Date: Sun, 11 Oct 2026 11:36:14 +0800 Subject: [PATCH 3/4] style(tests): satisfy ruff format for regression test ruff format --check reports three small hunks in the new regression test: blank line before the def, triple-quote style for the dedent source, and the with-statement header collapsed to one line. Apply them so the quality CI (ruff format --check tests) passes on this head. --- tests/test_tools.py | 140 +++++++++++++++++++++++++++++++++----------- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index d1d5d828ba..6edb4bfc81 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -25,7 +25,14 @@ import pytest from smolagents.agent_types import _AGENT_TYPE_MAPPING -from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments +from smolagents.tools import ( + AUTHORIZED_TYPES, + Tool, + ToolCollection, + launch_gradio_demo, + tool, + validate_tool_arguments, +) from .utils.markers import require_run_all @@ -62,7 +69,9 @@ def test_agent_type_output(self, create_inputs): @pytest.fixture def create_inputs(self, shared_datadir): - def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: + def _create_inputs( + tool_inputs: dict[str, dict[str | type, str]], + ) -> dict[str, Any]: inputs = {} for input_name, input_desc in tool_inputs.items(): @@ -71,7 +80,9 @@ def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, A if input_type == "string": inputs[input_name] = "Text input" elif input_type == "image": - inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) + inputs[input_name] = PIL.Image.open( + shared_datadir / "000000039769.png" + ).resize((512, 512)) elif input_type == "audio": inputs[input_name] = np.ones(3000) else: @@ -92,11 +103,17 @@ class TestTool: # Invalid cases ("invalid_type", ValueError, "must be one of"), (["string", "invalid_type"], ValueError, "must be one of"), - ([123, "string"], TypeError, "when type is a list, all elements must be strings"), + ( + [123, "string"], + TypeError, + "when type is a list, all elements must be strings", + ), (123, TypeError, "must be a string or list of strings"), ], ) - def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): + def test_tool_input_type_validation( + self, type_value, should_raise_error, error_contains + ): """Test the validation of the type property in tool inputs.""" # Define a tool class with the test type value @@ -126,7 +143,10 @@ def forward(self, text) -> str: @pytest.mark.parametrize( "tool_fixture, expected_output", [ - ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), + ( + "no_input_tool", + 'def no_input_tool() -> string:\n """Tool with no inputs\n """', + ), ( "single_input_tool", 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', @@ -141,7 +161,9 @@ def forward(self, text) -> str: ), ], ) - def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): + def test_tool_to_code_prompt_output_format( + self, tool_fixture, expected_output, request + ): """Test that to_code_prompt generates properly formatted and indented output.""" tool = request.getfixturevalue(tool_fixture) code_prompt = tool.to_code_prompt() @@ -168,7 +190,9 @@ def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, ), ], ) - def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): + def test_tool_to_tool_calling_prompt_output_format( + self, tool_fixture, expected_output, request + ): """Test that to_tool_calling_prompt generates properly formatted output.""" tool = request.getfixturevalue(tool_fixture) tool_calling_prompt = tool.to_tool_calling_prompt() @@ -297,7 +321,9 @@ def test_tool_to_dict_allows_no_arg_in_init(self): class FailTool(Tool): name = "specific" description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} + inputs = { + "string_input": {"type": "string", "description": "input description"} + } output_type = "string" def __init__(self, url): @@ -310,12 +336,17 @@ def forward(self, string_input: str) -> str: fail_tool = FailTool("dummy_url") with pytest.raises(Exception) as e: fail_tool.to_dict() - assert "Parameters in __init__ must have default values, found required parameters" in str(e) + assert ( + "Parameters in __init__ must have default values, found required parameters" + in str(e) + ) class PassTool(Tool): name = "specific" description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} + inputs = { + "string_input": {"type": "string", "description": "input description"} + } output_type = "string" def __init__(self, url: str | None = "none"): @@ -335,7 +366,9 @@ def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): class FailTool(Tool): name = "specific" description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} + inputs = { + "string_input": {"type": "string", "description": "input description"} + } output_type = "string" def useless_method(self): @@ -354,7 +387,9 @@ def forward(self, string_input): class SuccessTool(Tool): name = "specific" description = "test description" - inputs = {"string_input": {"type": "string", "description": "input description"}} + inputs = { + "string_input": {"type": "string", "description": "input description"} + } output_type = "string" def useless_method(self): @@ -499,7 +534,9 @@ def get_weather(location: Any) -> None: def test_tool_supports_array(self): @tool - def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: + def get_weather( + locations: list[str], months: tuple[str, str] | None = None + ) -> dict[str, float]: """ Get weather in the next days at given locations. @@ -555,7 +592,9 @@ def get_choice(choice: Literal[1, 2, 3, None]) -> None: assert get_choice.inputs["choice"]["nullable"] is True assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): + def test_saving_tool_produces_valid_python_code_with_multiline_description( + self, tmp_path + ): @tool def get_weather(location: Any) -> None: """ @@ -572,7 +611,9 @@ def get_weather(location: Any) -> None: source_code = f.read() compile(source_code, f.name, "exec") - @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) + @pytest.mark.parametrize( + "fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"] + ) def test_to_dict_boolean_default_input(self, fixture_name, request): """Test that boolean input parameter with default value is correctly represented in to_dict output""" tool = request.getfixturevalue(fixture_name) @@ -582,7 +623,9 @@ def test_to_dict_boolean_default_input(self, fixture_name, request): # Check nullable attribute is set for the parameter with default value assert "'nullable': True" in result["code"] - @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) + @pytest.mark.parametrize( + "fixture_name", ["optional_input_tool_class", "optional_input_tool_function"] + ) def test_to_dict_optional_input(self, fixture_name, request): """Test that Optional/nullable input parameter is correctly represented in to_dict output""" tool = request.getfixturevalue(fixture_name) @@ -739,7 +782,9 @@ def multiline_signature_tool( assert " count: int = 1," not in forward_source assert " count: int=1," not in forward_source - def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): + def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline( + self, + ): """Test that @tool works with both multiple decorators and multiline signatures.""" def dummy_decorator_1(func): @@ -818,8 +863,12 @@ def mock_smolagents_adapter(): # Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior @pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") class TestToolCollection: - def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): - with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: + def test_from_mcp( + self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter + ): + with ToolCollection.from_mcp( + mock_server_parameters, trust_remote_code=True + ) as tool_collection: assert isinstance(tool_collection, ToolCollection) assert len(tool_collection.tools) == 2 assert "tool1" in tool_collection.tools @@ -845,10 +894,16 @@ def echo_tool(text: str) -> str: args=["-c", mcp_server_script], ) - with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: + with ToolCollection.from_mcp( + mcp_server_params, trust_remote_code=True + ) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + assert tool_collection.tools[0].name == "echo_tool", ( + "Expected tool name to be 'echo_tool'" + ) + assert tool_collection.tools[0](text="Hello") == "Hello", ( + "Expected tool to echo the input text" + ) def test_integration_from_mcp_with_streamable_http(self): import subprocess @@ -877,11 +932,16 @@ def echo_tool(text: str) -> str: try: with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True + {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, + trust_remote_code=True, ) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + assert tool_collection.tools[0].name == "echo_tool", ( + "Expected tool name to be 'echo_tool'" + ) + assert tool_collection.tools[0](text="Hello") == "Hello", ( + "Expected tool to echo the input text" + ) finally: # clean up the process when test is done server_process.kill() @@ -914,11 +974,16 @@ def echo_tool(text: str) -> str: try: with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True + {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, + trust_remote_code=True, ) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" - assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" + assert tool_collection.tools[0].name == "echo_tool", ( + "Expected tool name to be 'echo_tool'" + ) + assert tool_collection.tools[0](text="Hello") == "Hello", ( + "Expected tool to echo the input text" + ) finally: # clean up the process when test is done server_process.kill() @@ -977,7 +1042,13 @@ def test_tool(argument_a: tool_input_type) -> str: # - Valid input ("required_unsupported_none", str, ..., "text", None), # - None not allowed - ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), + ( + "required_unsupported_none", + str, + ..., + None, + "Argument param has type 'null' but should be 'string'", + ), # - Missing required parameter is not allowed ("required_unsupported_none", str, ..., ..., "Argument param is required"), # @@ -1032,7 +1103,9 @@ def test_tool(argument_a: tool_input_type) -> str: ("optional_supported_none_none_default", str | None, None, ..., None), ], ) -def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): +def test_validate_tool_arguments_nullable( + scenario, type_hint, default, input_value, expected_error_message +): """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" # Create a tool with the appropriate signature @@ -1067,6 +1140,7 @@ def test_tool(param: type_hint = default) -> str: # Should not raise any exception validate_tool_arguments(test_tool, input_dict) + def test_from_hub_pops_hub_download_options(tmp_path): """Regression for #2928: Hub download options must not reach the tool constructor. @@ -1075,7 +1149,7 @@ def test_from_hub_pops_hub_download_options(tmp_path): constructor must not fail because ``cache_dir`` leaked into ``__init__``. """ tool_source = dedent( - ''' + """ from smolagents import Tool @@ -1091,7 +1165,7 @@ def __init__(self, suffix: str | None = None): def forward(self, value: int) -> int: return value - ''' + """ ) tool_path = tmp_path / "tool.py" tool_path.write_text(tool_source) From 2f255d415aeb12d50b5c381c7c2ccf3b9753838b Mon Sep 17 00:00:00 2001 From: TINGyu123644 <2663180454@qq.com> Date: Sun, 11 Oct 2026 11:38:17 +0800 Subject: [PATCH 4/4] style(tests): apply minimal ruff format fixes to regression test Only the three hunks ruff format --check (line-length 119) reports for the new regression test: blank line before the def, triple-quote style of the dedent source, and the with-statement header collapsed to one line. --- tests/test_tools.py | 139 ++++++++++---------------------------------- 1 file changed, 32 insertions(+), 107 deletions(-) diff --git a/tests/test_tools.py b/tests/test_tools.py index 6edb4bfc81..710bfbbe6d 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -25,14 +25,7 @@ import pytest from smolagents.agent_types import _AGENT_TYPE_MAPPING -from smolagents.tools import ( - AUTHORIZED_TYPES, - Tool, - ToolCollection, - launch_gradio_demo, - tool, - validate_tool_arguments, -) +from smolagents.tools import AUTHORIZED_TYPES, Tool, ToolCollection, launch_gradio_demo, tool, validate_tool_arguments from .utils.markers import require_run_all @@ -69,9 +62,7 @@ def test_agent_type_output(self, create_inputs): @pytest.fixture def create_inputs(self, shared_datadir): - def _create_inputs( - tool_inputs: dict[str, dict[str | type, str]], - ) -> dict[str, Any]: + def _create_inputs(tool_inputs: dict[str, dict[str | type, str]]) -> dict[str, Any]: inputs = {} for input_name, input_desc in tool_inputs.items(): @@ -80,9 +71,7 @@ def _create_inputs( if input_type == "string": inputs[input_name] = "Text input" elif input_type == "image": - inputs[input_name] = PIL.Image.open( - shared_datadir / "000000039769.png" - ).resize((512, 512)) + inputs[input_name] = PIL.Image.open(shared_datadir / "000000039769.png").resize((512, 512)) elif input_type == "audio": inputs[input_name] = np.ones(3000) else: @@ -103,17 +92,11 @@ class TestTool: # Invalid cases ("invalid_type", ValueError, "must be one of"), (["string", "invalid_type"], ValueError, "must be one of"), - ( - [123, "string"], - TypeError, - "when type is a list, all elements must be strings", - ), + ([123, "string"], TypeError, "when type is a list, all elements must be strings"), (123, TypeError, "must be a string or list of strings"), ], ) - def test_tool_input_type_validation( - self, type_value, should_raise_error, error_contains - ): + def test_tool_input_type_validation(self, type_value, should_raise_error, error_contains): """Test the validation of the type property in tool inputs.""" # Define a tool class with the test type value @@ -143,10 +126,7 @@ def forward(self, text) -> str: @pytest.mark.parametrize( "tool_fixture, expected_output", [ - ( - "no_input_tool", - 'def no_input_tool() -> string:\n """Tool with no inputs\n """', - ), + ("no_input_tool", 'def no_input_tool() -> string:\n """Tool with no inputs\n """'), ( "single_input_tool", 'def single_input_tool(text: string) -> string:\n """Tool with one input\n\n Args:\n text: Input text\n """', @@ -161,9 +141,7 @@ def forward(self, text) -> str: ), ], ) - def test_tool_to_code_prompt_output_format( - self, tool_fixture, expected_output, request - ): + def test_tool_to_code_prompt_output_format(self, tool_fixture, expected_output, request): """Test that to_code_prompt generates properly formatted and indented output.""" tool = request.getfixturevalue(tool_fixture) code_prompt = tool.to_code_prompt() @@ -190,9 +168,7 @@ def test_tool_to_code_prompt_output_format( ), ], ) - def test_tool_to_tool_calling_prompt_output_format( - self, tool_fixture, expected_output, request - ): + def test_tool_to_tool_calling_prompt_output_format(self, tool_fixture, expected_output, request): """Test that to_tool_calling_prompt generates properly formatted output.""" tool = request.getfixturevalue(tool_fixture) tool_calling_prompt = tool.to_tool_calling_prompt() @@ -321,9 +297,7 @@ def test_tool_to_dict_allows_no_arg_in_init(self): class FailTool(Tool): name = "specific" description = "test description" - inputs = { - "string_input": {"type": "string", "description": "input description"} - } + inputs = {"string_input": {"type": "string", "description": "input description"}} output_type = "string" def __init__(self, url): @@ -336,17 +310,12 @@ def forward(self, string_input: str) -> str: fail_tool = FailTool("dummy_url") with pytest.raises(Exception) as e: fail_tool.to_dict() - assert ( - "Parameters in __init__ must have default values, found required parameters" - in str(e) - ) + assert "Parameters in __init__ must have default values, found required parameters" in str(e) class PassTool(Tool): name = "specific" description = "test description" - inputs = { - "string_input": {"type": "string", "description": "input description"} - } + inputs = {"string_input": {"type": "string", "description": "input description"}} output_type = "string" def __init__(self, url: str | None = "none"): @@ -366,9 +335,7 @@ def test_saving_tool_allows_no_imports_from_outside_methods(self, tmp_path): class FailTool(Tool): name = "specific" description = "test description" - inputs = { - "string_input": {"type": "string", "description": "input description"} - } + inputs = {"string_input": {"type": "string", "description": "input description"}} output_type = "string" def useless_method(self): @@ -387,9 +354,7 @@ def forward(self, string_input): class SuccessTool(Tool): name = "specific" description = "test description" - inputs = { - "string_input": {"type": "string", "description": "input description"} - } + inputs = {"string_input": {"type": "string", "description": "input description"}} output_type = "string" def useless_method(self): @@ -534,9 +499,7 @@ def get_weather(location: Any) -> None: def test_tool_supports_array(self): @tool - def get_weather( - locations: list[str], months: tuple[str, str] | None = None - ) -> dict[str, float]: + def get_weather(locations: list[str], months: tuple[str, str] | None = None) -> dict[str, float]: """ Get weather in the next days at given locations. @@ -592,9 +555,7 @@ def get_choice(choice: Literal[1, 2, 3, None]) -> None: assert get_choice.inputs["choice"]["nullable"] is True assert get_choice.inputs["choice"]["enum"] == [1, 2, 3] - def test_saving_tool_produces_valid_python_code_with_multiline_description( - self, tmp_path - ): + def test_saving_tool_produces_valid_python_code_with_multiline_description(self, tmp_path): @tool def get_weather(location: Any) -> None: """ @@ -611,9 +572,7 @@ def get_weather(location: Any) -> None: source_code = f.read() compile(source_code, f.name, "exec") - @pytest.mark.parametrize( - "fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"] - ) + @pytest.mark.parametrize("fixture_name", ["boolean_default_tool_class", "boolean_default_tool_function"]) def test_to_dict_boolean_default_input(self, fixture_name, request): """Test that boolean input parameter with default value is correctly represented in to_dict output""" tool = request.getfixturevalue(fixture_name) @@ -623,9 +582,7 @@ def test_to_dict_boolean_default_input(self, fixture_name, request): # Check nullable attribute is set for the parameter with default value assert "'nullable': True" in result["code"] - @pytest.mark.parametrize( - "fixture_name", ["optional_input_tool_class", "optional_input_tool_function"] - ) + @pytest.mark.parametrize("fixture_name", ["optional_input_tool_class", "optional_input_tool_function"]) def test_to_dict_optional_input(self, fixture_name, request): """Test that Optional/nullable input parameter is correctly represented in to_dict output""" tool = request.getfixturevalue(fixture_name) @@ -782,9 +739,7 @@ def multiline_signature_tool( assert " count: int = 1," not in forward_source assert " count: int=1," not in forward_source - def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline( - self, - ): + def test_tool_decorator_source_extraction_with_multiple_decorators_and_multiline(self): """Test that @tool works with both multiple decorators and multiline signatures.""" def dummy_decorator_1(func): @@ -863,12 +818,8 @@ def mock_smolagents_adapter(): # Ignore FutureWarning about structured_output default value change: this test intentionally uses default behavior @pytest.mark.filterwarnings("ignore:.*structured_output:FutureWarning") class TestToolCollection: - def test_from_mcp( - self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter - ): - with ToolCollection.from_mcp( - mock_server_parameters, trust_remote_code=True - ) as tool_collection: + def test_from_mcp(self, mock_server_parameters, mock_mcp_adapt, mock_smolagents_adapter): + with ToolCollection.from_mcp(mock_server_parameters, trust_remote_code=True) as tool_collection: assert isinstance(tool_collection, ToolCollection) assert len(tool_collection.tools) == 2 assert "tool1" in tool_collection.tools @@ -894,16 +845,10 @@ def echo_tool(text: str) -> str: args=["-c", mcp_server_script], ) - with ToolCollection.from_mcp( - mcp_server_params, trust_remote_code=True - ) as tool_collection: + with ToolCollection.from_mcp(mcp_server_params, trust_remote_code=True) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", ( - "Expected tool name to be 'echo_tool'" - ) - assert tool_collection.tools[0](text="Hello") == "Hello", ( - "Expected tool to echo the input text" - ) + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" def test_integration_from_mcp_with_streamable_http(self): import subprocess @@ -932,16 +877,11 @@ def echo_tool(text: str) -> str: try: with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, - trust_remote_code=True, + {"url": "http://127.0.0.1:8000/mcp", "transport": "streamable-http"}, trust_remote_code=True ) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", ( - "Expected tool name to be 'echo_tool'" - ) - assert tool_collection.tools[0](text="Hello") == "Hello", ( - "Expected tool to echo the input text" - ) + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" finally: # clean up the process when test is done server_process.kill() @@ -974,16 +914,11 @@ def echo_tool(text: str) -> str: try: with ToolCollection.from_mcp( - {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, - trust_remote_code=True, + {"url": "http://127.0.0.1:8000/sse", "transport": "sse"}, trust_remote_code=True ) as tool_collection: assert len(tool_collection.tools) == 1, "Expected 1 tool" - assert tool_collection.tools[0].name == "echo_tool", ( - "Expected tool name to be 'echo_tool'" - ) - assert tool_collection.tools[0](text="Hello") == "Hello", ( - "Expected tool to echo the input text" - ) + assert tool_collection.tools[0].name == "echo_tool", "Expected tool name to be 'echo_tool'" + assert tool_collection.tools[0](text="Hello") == "Hello", "Expected tool to echo the input text" finally: # clean up the process when test is done server_process.kill() @@ -1042,13 +977,7 @@ def test_tool(argument_a: tool_input_type) -> str: # - Valid input ("required_unsupported_none", str, ..., "text", None), # - None not allowed - ( - "required_unsupported_none", - str, - ..., - None, - "Argument param has type 'null' but should be 'string'", - ), + ("required_unsupported_none", str, ..., None, "Argument param has type 'null' but should be 'string'"), # - Missing required parameter is not allowed ("required_unsupported_none", str, ..., ..., "Argument param is required"), # @@ -1103,9 +1032,7 @@ def test_tool(argument_a: tool_input_type) -> str: ("optional_supported_none_none_default", str | None, None, ..., None), ], ) -def test_validate_tool_arguments_nullable( - scenario, type_hint, default, input_value, expected_error_message -): +def test_validate_tool_arguments_nullable(scenario, type_hint, default, input_value, expected_error_message): """Test validation of tool arguments with focus on nullable properties: optional (with default value) and supporting None value.""" # Create a tool with the appropriate signature @@ -1170,9 +1097,7 @@ def forward(self, value: int) -> int: tool_path = tmp_path / "tool.py" tool_path.write_text(tool_source) - with patch( - "smolagents.tools.hf_hub_download", return_value=str(tool_path) - ) as mock_download: + with patch("smolagents.tools.hf_hub_download", return_value=str(tool_path)) as mock_download: restored = Tool.from_hub( "some-user/some-tool", trust_remote_code=True,