Source code for toolfuse.base

import inspect
import re
from abc import ABC
from importlib.metadata import version as pkgversion
from inspect import getdoc, getmodule
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union

from jsonschema import ValidationError, validate
from toolcore import FunctionWrapper

from .models import V1ToolRef


[docs] class Action: """ Represents an action that an agent can perform in an environment. This class is used to encapsulate information about an action, including its name, the method that implements the action, the schema that defines the structure of the action's parameters, and a human-readable description of the action. Attributes: name (str): The name of the action. This is typically a unique identifier. method (Callable): The callable method that is executed when the action is taken. schema (Dict): A dictionary that defines the structure and types of the parameters that the action expects. description (str): A human-readable description of what the action does and its purpose within the context of the agent's environment. Methods: __call__(*args, **kwargs) -> Any: Allows the Action instance to be called like a function, which will in turn call the method associated with this action, passing through any arguments and keyword arguments. """ def __init__(self, name: str, method: Callable, schema: Dict, description: str): """ Initializes a new instance of the Action class. Args: name (str): The name of the action. method (Callable): The callable method that implements the action. schema (Dict): The schema defining the structure of the action's parameters. description (str): A description of the action. """ self.name = name self.method = method self.schema = schema self.description = description
[docs] def __call__(self, *args, **kwargs) -> Any: """ Executes the action's method with the given arguments and keyword arguments. Args: *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. Returns: The result of the action method execution. """ return self.method(*args, **kwargs)
[docs] class Observation(Action): """ Represents an observation that an agent can make in an environment. This class is used to encapsulate information about an observation, including its name, the method that implements the observation, the schema that defines the structure of the observation's data, and a human-readable description of the observation. Attributes: name (str): The name of the observation. This is typically a unique identifier. method (Callable): The callable method that is executed to obtain the observation. schema (Dict): A dictionary that defines the structure and types of the data that the observation returns. description (str): A human-readable description of what the observation represents and its purpose within the context of the agent's environment. Methods: __call__(*args, **kwargs) -> Any: Allows the Observation instance to be called like a function, which will in turn call the method associated with this observation, passing through any arguments and keyword arguments. """ def __init__(self, name: str, method: Callable, schema: Dict, description: str): """ Initializes a new instance of the Observation class. Args: name (str): The name of the observation. method (Callable): The callable method that implements the observation. schema (Dict): The schema defining the structure of the observation's data. description (str): A description of the observation. """ self.name = name self.method = method self.schema = schema self.description = description
[docs] def __call__(self, *args, **kwargs) -> Any: """ Executes the observation's method with the given arguments and keyword arguments. This method allows the observation to be obtained in a manner similar to calling a function, with the observation's method determining the exact data returned. Args: *args: Variable length argument list. **kwargs: Arbitrary keyword arguments. Returns: The result of the observation method execution, typically data about the environment or the agent's state within it. """ return self.method(*args, **kwargs)
[docs] def action(method: Callable) -> Callable: """ A decorator that marks a method as an action within the Tool. This decorator adds a special attribute to the method indicating that it should be treated as an action. Actions are potential operations that an agent can perform in its environment. Args: method (Callable): The method to be marked as an action. Returns: Callable: The original method with the added '_is_action' attribute. """ method._is_action = True # type: ignore return method
[docs] def observation(method: Callable) -> Callable: """ A decorator that marks a method as an observation within the Tool. This decorator adds a special attribute to the method indicating that it should be treated as an observation. Observations are pieces of information that an agent can gather from its environment to inform its decision-making process. Args: method (Callable): The method to be marked as an observation. Returns: Callable: The original method with the added '_is_observation' attribute. """ method._is_observation = True # type: ignore return method
T = TypeVar("T")
[docs] class Tool(ABC): """ A Tool is an abstract base class that defines the interface for agent tools. Agent tools are the primary means by which an agent interacts with its environment. They encapsulate actions that an agent can perform and observations that an agent can make to understand the state of the environment. This class provides the necessary infrastructure for registering and managing these actions and observations. """ def __init__(self, wraps: Optional["Tool"] = None) -> None: """ Initializes a new instance of the Tool class, setting up the lists for actions and observations and registering the methods defined in the subclass. Args: wraps (Tool): An optional Tool instance that this Tool instance should wrap. """ self._actions_list: List[Action] = [] self._observations_list: List[Observation] = [] self._register_methods() self.wraps = wraps def _register_methods(self) -> None: """ Scans the Tool instance for methods marked as actions or observations and registers them. This method looks for callable attributes of the instance that have either the '_is_action' or '_is_observation' attribute set to True, indicating that they should be treated as actions or observations, respectively. It then creates Action or Observation instances for these methods and adds them to the appropriate list. """ for attr_name in dir(self): attr = getattr(self, attr_name) if callable(attr) and hasattr(attr, "_is_action"): wrapper = FunctionWrapper(attr) description = self._parse_docstring(attr) action = Action(attr_name, attr, wrapper.schema, description) self._actions_list.append(action) elif callable(attr) and hasattr(attr, "_is_observation"): wrapper = FunctionWrapper(attr) description = self._parse_docstring(attr) observation = Observation(attr_name, attr, wrapper.schema, description) self._observations_list.append(observation) def _parse_docstring(self, method: Callable) -> str: """ Extracts the first sentence from a method's docstring to use as a description. Args: method (Callable): The method from which to extract the docstring. Returns: str: The first sentence of the method's docstring, if available. Otherwise, an empty string. """ docstring = getdoc(method) if docstring: return re.split(r"\.\s+", docstring)[0] return ""
[docs] def actions(self) -> List[Action]: """ Returns a list of all registered actions that the agent can take. Each action represents a potential operation that the agent can perform in its environment. Returns: List[Action]: A list of Action instances representing the available actions. """ out = self._actions_list if self.wraps: out.extend(self.wraps._actions_list) return out
[docs] def observations(self) -> List[Observation]: """ Returns a list of all registered observations that the agent can make. Each observation represents a piece of information that the agent can gather from its environment to inform its decision-making process. Returns: List[Observation]: A list of Observation instances representing the available observations. """ out = self._observations_list if self.wraps: out.extend(self.wraps._observations_list) return out
[docs] def use(self, action: Action, *args, **kwargs) -> Any: """ Executes an action with the provided arguments and keyword arguments. Args: action (Action): The Action instance representing the action to perform. *args: Variable length argument list for the action. **kwargs: Arbitrary keyword arguments for the action. Returns: Any: The result of the action execution, which can vary depending on the action. """ self._validate_parameters(action.schema, kwargs) return action(*args, **kwargs)
[docs] def observe(self, observation: Observation, *args, **kwargs) -> Any: """ Executes an observation with the provided arguments and keyword arguments. Args: observation (Observation): The Observation instance representing the observation to run. *args: Variable length argument list for the observation. **kwargs: Arbitrary keyword arguments for the observation. Returns: Any: The result of the observation execution, which can vary depending on the observation. """ self._validate_parameters(observation.schema, kwargs) if not isinstance(observation, Observation): raise ValueError( "Actions are not observable. Use the 'use' method to perform an action." ) return observation(*args, **kwargs)
[docs] def json_schema( self, actions_only: bool = False, exclude_names: List[str] = [] ) -> List[Dict[str, Any]]: """ Returns a list of JSON schemas representing the tool's actions and, optionally, observations, excluding any actions or observations with names listed in 'exclude_names'. Each schema provides a structured description of an action's or observation's interface, including its name, expected arguments, and other metadata. Args: actions_only (bool, optional): If True, only the action schemas will be returned. Defaults to False. exclude_names (List[str], optional): A list of action or observation names to exclude from the schema output. Returns: List[Dict[str, Any]]: A list of dictionaries, each representing the JSON schema of an action or observation not excluded. """ out = [] for action in self.actions(): if action.name not in exclude_names: out.append(action.schema) if not actions_only: for observation in self.observations(): if observation.name not in exclude_names: out.append(observation.schema) return out
def _validate_parameters( self, schema: Dict[str, Any], parameters: Dict[str, Any] ) -> None: """ Validates the provided parameters against the given schema. Args: schema (Dict[str, Any]): The schema defining the structure of the parameters. parameters (Dict[str, Any]): The parameters to validate. Raises: ValidationError: If the parameters do not conform to the schema. """ try: validate(instance=parameters, schema=schema) except ValidationError as e: raise ValueError(f"Parameter validation error: {e.message}")
[docs] def find_action(self, name: str) -> Optional[Action]: """ Searches for an action or observation by name and returns it if found. This method checks both the actions and observations lists for a match. Args: name (str): The name of the action or observation to find. Returns: Optional[Action]: The Action or Observation instance with the matching name, or None if not found. """ if self.wraps: for action in self.wraps.actions(): if action.name == name: return action for observation in self.wraps.observations(): if observation.name == name: return observation for action in self.actions(): if action.name == name: return action for observation in self.observations(): if observation.name == name: return observation
[docs] def close(self) -> None: """ A method that should be implemented by subclasses to handle the closing of the tool. This method is intended to provide a way to release any resources or perform any cleanup necessary when the tool is no longer needed. """ return
[docs] def context(self) -> str: """LLM context fork the tool Returns: str: LLM context for the tool """ return self.__doc__ # type: ignore
[docs] @classmethod def type(cls) -> str: """Tool type Returns: str: Tool type """ return cls.__name__
[docs] def ref(self) -> V1ToolRef: """Tool reference""" module = getmodule(self) if not module: raise ValueError("Tool not associated with a module") mod_parts = module.__name__.split(".") version = None try: version = pkgversion(mod_parts[0]) except: pass return V1ToolRef(module=module.__name__, type=self.type(), version=version)
[docs] def add_action(self, method: Callable) -> None: """ Adds a new action to the tool using only the method provided. Name, schema, and description are derived automatically. Args: method (Callable): The callable method that implements the action. """ name = method.__name__ schema = self._generate_schema( method ) # Assuming a method to generate schema automatically description = self._parse_docstring(method) action = Action(name, method, schema, description) self._actions_list.append(action)
[docs] def add_observation(self, method: Callable) -> None: """ Adds a new observation to the tool using only the method provided. Name, schema, and description are derived automatically. Args: method (Callable): The callable method that implements the observation. """ name = method.__name__ schema = self._generate_schema( method ) # Assuming a method to generate schema automatically description = self._parse_docstring(method) observation = Observation(name, method, schema, description) self._observations_list.append(observation)
def _generate_schema(self, method: Callable) -> Dict[str, Any]: """ Generates a schema based on the method signature. This can be as simple or as complex as needed depending on how parameters are to be handled. Args: method (Callable): The method for which to generate a schema. Returns: Dict[str, Any]: A schema representing the parameters and their types. """ # Example implementation using inspect to generate parameter types params = inspect.signature(method).parameters return {param: str(ptype.annotation) for param, ptype in params.items()}
[docs] def add_actions(self, methods: List[Callable]) -> None: """ Adds multiple actions to the tool using a list of methods. Each method's name, schema, and description are derived automatically. Args: methods (List[Callable]): A list of callable methods that implement actions. """ for method in methods: self.add_action(method)
[docs] def add_observations(self, methods: List[Callable]) -> None: """ Adds multiple observations to the tool using a list of methods. Each method's name, schema, and description are derived automatically. Args: methods (List[Callable]): A list of callable methods that implement observations. """ for method in methods: self.add_observation(method)
def _add_action( self, method: Callable, name: Optional[str] = None, schema: Optional[Dict] = None, description: Optional[str] = None, ) -> None: """ Adds a new action to the tool using the provided method. If the action with the same name already exists, it won't add it again. """ if not name: name = method.__name__ if any(action.name == name for action in self._actions_list): return # Prevent duplicate actions action = Action( name, method, schema or {}, description or self._parse_docstring(method) ) self._actions_list.append(action) def _add_observation( self, method: Callable, name: Optional[str] = None, schema: Optional[Dict] = None, description: Optional[str] = None, ) -> None: """ Adds a new observation to the tool using the provided method. """ if not name: name = method.__name__ if any(obs.name == name for obs in self._observations_list): return # Prevent duplicate observations observation = Observation( name, method, schema or {}, description or self._parse_docstring(method) ) self._observations_list.append(observation)
[docs] def merge(self, other: "Tool") -> None: """ Merges the actions and observations from another tool into this tool. Args: other (Tool): The tool to merge into this tool. """ for action in other.actions(): self._add_action( action.method, action.name, action.schema, action.description ) for observation in other.observations(): self._add_observation( observation.method, observation.name, observation.schema, observation.description, )
def tool_from_cls(cls: Type[T]) -> Type[Tool]: """ Dynamically creates a subclass of `Tool` that integrates methods from a given class `cls` as actions. Args: cls (Type[T]): The class from which to create a Tool, integrating its methods as actions. Returns: Type[Tool]: A new subclass of Tool that includes actions derived from `cls` methods. """ class Combined(Tool, cls): """ A combined class that inherits from both Tool and a user-defined class (cls). This class is dynamically created to combine the functionality of a user-defined class with the Tool class, allowing methods from the user-defined class to be registered as actions within the Tool framework. It initializes both parent classes and registers the user-defined class's methods as actions. Attributes: Inherits all attributes from the Tool class and the user-defined class (cls). Methods: __init__(*args, **kwargs): Initializes the Combined class, the Tool part of the class, and registers methods from the user-defined class as actions. type(): Returns the type of the class. _register_methods_from_cls(): Registers public methods from the user-defined class as actions. """ def __init__(self, *args, **kwargs): """ Initializes the Combined class by initializing both the user-defined class part and the Tool class part. It also registers the methods from the user-defined class (cls) as actions within the Tool framework. Args: *args: Variable length argument list passed to the user-defined class initializer. **kwargs: Arbitrary keyword arguments passed to the user-defined class initializer. """ cls.__init__( self, *args, **kwargs # type: ignore ) # Initialize the user-defined part of the combined class Tool.__init__(self) # Initialize the Tool part of the combined class self._register_methods_from_cls() # Register methods from cls as actions @classmethod def type(cls) -> str: """ Returns the type of the class. Returns: str: The type of the class. """ return cls.__name__ def _register_methods_from_cls(self): """ Registers all public methods from the user-defined class (cls) as actions. This method iterates over all public methods of cls and registers them as actions, allowing them to be utilized within the Tool framework. """ for name, method in inspect.getmembers(self, predicate=inspect.ismethod): # Skip private and protected methods, skip methods from Tool or object class if ( name.startswith("_") or inspect.getmodule(method) == inspect.getmodule(Tool) or inspect.getmodule(method) == inspect.getmodule(object) ): continue # Wrap the method to get its schema wrapper = FunctionWrapper(method) # Use the existing method to create a description based on the method's docstring description = self._parse_docstring(method) # Use the schema generated by FunctionWrapper schema = wrapper.schema # Create an Action instance for the method action = Action(name, method, schema, description) # Add the Action to the tool's actions list self._actions_list.append(action) Combined.__name__ = f"{cls.__name__}Tool" return Combined def tool_from_function(function: Callable) -> Type[Tool]: """ Dynamically creates a subclass of `Tool` that encapsulates a given function as an action. Args: function (Callable): The function to be encapsulated as an action in the Tool. Returns: Type[Tool]: A new subclass of Tool that includes the given function as an action. """ class FunctionTool(Tool): """ A subclass of `Tool` designed to encapsulate a single function as an actionable method within the Tool framework. This class takes a function upon initialization and registers it as an action, making it callable within the context of the Tool's environment. The primary purpose of this class is to allow standalone functions to be seamlessly integrated into the Tool framework, providing a straightforward way to extend functionality with custom actions. Attributes: function (Callable): The function that is encapsulated as an action within the Tool. Methods: __init__(self, function: Callable): Initializes a new instance of the FunctionTool class, registering the provided function as an action. _register_function_as_action(self, function: Callable): Registers the provided function as an action for the Tool. """ def __init__(self): """ Initializes a new instance of the FunctionTool class. This constructor method takes a function as an argument and calls the internal method to register it as an action within the Tool. Args: function (Callable): The function to be encapsulated as an action in the Tool. """ super().__init__() self._register_function_as_action(function) def _register_function_as_action(self, function: Callable): """ Registers the provided function as an action for the Tool. This method wraps the given function to extract its schema and documentation, creating an Action instance that encapsulates the function. This allows the function to be called as an action within the Tool's environment. Args: function (Callable): The function to be registered as an action. """ # Wrap the function to get its schema wrapper = FunctionWrapper(function) # Generate a description from the function's docstring description = self._parse_docstring(function) # Use the schema generated by FunctionWrapper schema = wrapper.schema # Extract the name of the function to use as the action name name = function.__name__ # Create an Action instance for the function action = Action(name, function, schema, description) # Add the Action to the tool's actions list self._actions_list.append(action) FunctionTool.__name__ = f"{function.__name__}_tool" return FunctionTool def tool_from_object(obj: Any) -> Tool: """ Dynamically creates a subclass of `Tool` that encapsulates the methods of a given object instance as actions. Args: obj (Any): The object instance whose methods are to be encapsulated as actions in the Tool. Returns: Tool: A new subclass of Tool that includes the object's methods as actions. """ class ObjectTool(Tool): """ A subclass of `Tool` that encapsulates the methods of a given object instance as actions. This class dynamically creates actions based on the public methods of the provided object instance. Each method is wrapped as an `Action` object, allowing it to be invoked within the Tool's environment. Attributes: obj_instance (Any): The object instance whose methods are to be encapsulated as actions. Methods: __init__(self, obj_instance: Any): Initializes a new instance of `ObjectTool` with the given object. _register_methods_from_object(self): Registers all public methods of `obj_instance` as actions. """ def __init__(self, obj_instance: Any): """ Initializes a new instance of `ObjectTool`. This constructor method takes an object instance as an argument and calls the internal method to register its public methods as actions within the Tool. Args: obj_instance (Any): The object instance whose methods are to be encapsulated as actions. """ super().__init__() self.obj_instance = obj_instance self._register_methods_from_object() def _register_methods_from_object(self): """ Registers all public methods of the object instance as actions for the Tool. This method iterates over all public methods of the provided object instance, excluding private, protected, and dunder methods. Each method is wrapped as an `Action` object and added to the tool's actions list. """ for name, method in inspect.getmembers( self.obj_instance, predicate=inspect.ismethod ): # Skip private and protected methods, and skip dunder methods if name.startswith("_"): continue # Skip methods not defined in the class of obj_instance (e.g., inherited from object) if inspect.getmodule(method.__func__) != inspect.getmodule( self.obj_instance.__class__ ): continue # Wrap the method to get its schema wrapper = FunctionWrapper(method) # Generate a description from the method's docstring description = self._parse_docstring(method) # Use the schema generated by FunctionWrapper schema = wrapper.schema # Create an Action instance for the method action = Action(name, method, schema, description) # Add the Action to the tool's actions list self._actions_list.append(action) ObjectTool.__name__ = f"{obj.__class__.__name__}Tool" # Return an instance of ObjectTool instead of the class itself, as we need to pass the object instance to it return ObjectTool(obj) # type: ignore def tool(input: Union[Type, Callable, Any]) -> Union[Type[Tool], Tool]: """ Dynamically creates a Tool instance or class based on the type of the input. This function determines whether the input is a class, a function, or an object instance and then calls the appropriate tool creation function (`tool_from_cls`, `tool_from_function`, or `tool_from_object`). Args: input (Union[Type, Callable, Any]): The input for which to create a Tool. This can be a class, a function, or an object instance. Returns: Union[Type[Tool], Tool]: A Tool class if the input is a class or function, or a Tool instance if the input is an object instance. """ if inspect.isclass(input): # Input is a class, so we use tool_from_cls return tool_from_cls(input) elif inspect.isfunction(input): # Input is a function, so we use tool_from_function return tool_from_function(input) else: # Assume the input is an object instance, so we use tool_from_object return tool_from_object(input)