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,