from typing import Any, Dict, Optional, Callable, get_type_hints, Type, TypeVar, Union, List from pydantic import BaseModel, Field, validate_call from docstring_parser import parse from phi.model.message import Message from phi.utils.log import logger T = TypeVar("T") class ToolCallException(Exception): def __init__( self, exc, user_message: Optional[Union[str, Message]] = None, agent_message: Optional[Union[str, Message]] = None, messages: Optional[List[Union[dict, Message]]] = None, stop_execution: bool = False, ): super().__init__(exc) self.user_message = user_message self.agent_message = agent_message self.messages = messages self.stop_execution = stop_execution class RetryAgentRun(ToolCallException): """Exception raised when a tool call should be retried.""" class StopAgentRun(ToolCallException): """Exception raised when an agent should stop executing entirely.""" def __init__( self, exc, user_message: Optional[Union[str, Message]] = None, agent_message: Optional[Union[str, Message]] = None, messages: Optional[List[Union[dict, Message]]] = None, ): super().__init__( exc, user_message=user_message, agent_message=agent_message, messages=messages, stop_execution=True ) def get_entrypoint_docstring(entrypoint: Callable) -> str: from inspect import getdoc doc = getdoc(entrypoint) if not doc: return "" parsed = parse(doc) # Combine short and long descriptions lines = [] if parsed.short_description: lines.append(parsed.short_description) if parsed.long_description: lines.extend(parsed.long_description.split("\n")) return "\n".join(lines) class Function(BaseModel): """Model for storing functions that can be called by an agent.""" # The name of the function to be called. # Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64. name: str # A description of what the function does, used by the model to choose when and how to call the function. description: Optional[str] = None # The parameters the functions accepts, described as a JSON Schema object. # To describe a function that accepts no parameters, provide the value {"type": "object", "properties": {}}. parameters: Dict[str, Any] = Field( default_factory=lambda: {"type": "object", "properties": {}, "required": []}, description="JSON Schema object describing function parameters", ) strict: Optional[bool] = None # The function to be called. entrypoint: Optional[Callable] = None # If True, the arguments are sanitized before being passed to the function. sanitize_arguments: bool = True # If True, the function call will show the result along with sending it to the model. show_result: bool = False # If True, the agent will stop after the function call. stop_after_tool_call: bool = False # Hook that runs before the function is executed. # If defined, can accept the FunctionCall instance as a parameter. pre_hook: Optional[Callable] = None # Hook that runs after the function is executed, regardless of success/failure. # If defined, can accept the FunctionCall instance as a parameter. post_hook: Optional[Callable] = None # --*-- FOR INTERNAL USE ONLY --*-- # The agent that the function is associated with _agent: Optional[Any] = None def to_dict(self) -> Dict[str, Any]: return self.model_dump(exclude_none=True, include={"name", "description", "parameters", "strict"}) @classmethod def from_callable(cls, c: Callable, strict: bool = False) -> "Function": from inspect import getdoc, signature from phi.utils.json_schema import get_json_schema function_name = c.__name__ parameters = {"type": "object", "properties": {}, "required": []} try: sig = signature(c) type_hints = get_type_hints(c) # If function has an the agent argument, remove the agent parameter from the type hints if "agent" in sig.parameters: del type_hints["agent"] # logger.info(f"Type hints for {function_name}: {type_hints}") param_type_hints = { name: type_hints.get(name) for name in sig.parameters if name != "return" and name != "agent" } # Parse docstring for parameters param_descriptions = {} if docstring := getdoc(c): parsed_doc = parse(docstring) param_docs = parsed_doc.params if param_docs is not None: for param in param_docs: param_name = param.arg_name param_type = param.type_name param_descriptions[param_name] = f"({param_type}) {param.description}" # Get JSON schema for parameters only parameters = get_json_schema( type_hints=param_type_hints, param_descriptions=param_descriptions, strict=strict ) # If strict=True mark all fields as required # See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas#all-fields-must-be-required if strict: parameters["required"] = [name for name in parameters["properties"] if name != "agent"] else: # Mark a field as required if it has no default value parameters["required"] = [ name for name, param in sig.parameters.items() if param.default == param.empty and name != "self" and name != "agent" ] # logger.debug(f"JSON schema for {function_name}: {parameters}") except Exception as e: logger.warning(f"Could not parse args for {function_name}: {e}", exc_info=True) return cls( name=function_name, description=get_entrypoint_docstring(entrypoint=c), parameters=parameters, entrypoint=validate_call(c), ) def process_entrypoint(self, strict: bool = False): """Process the entrypoint and make it ready for use by an agent.""" from inspect import getdoc, signature from phi.utils.json_schema import get_json_schema if self.entrypoint is None: return parameters = {"type": "object", "properties": {}, "required": []} params_set_by_user = False # If the user set the parameters (i.e. they are different from the default), we should keep them if self.parameters != parameters: params_set_by_user = True try: sig = signature(self.entrypoint) type_hints = get_type_hints(self.entrypoint) # If function has an the agent argument, remove the agent parameter from the type hints if "agent" in sig.parameters: del type_hints["agent"] # logger.info(f"Type hints for {self.name}: {type_hints}") # Filter out return type and only process parameters param_type_hints = { name: type_hints.get(name) for name in sig.parameters if name != "return" and name != "agent" } # Parse docstring for parameters param_descriptions = {} if docstring := getdoc(self.entrypoint): parsed_doc = parse(docstring) param_docs = parsed_doc.params if param_docs is not None: for param in param_docs: param_name = param.arg_name param_type = param.type_name # TODO: We should use type hints first, then map param types in docs to json schema types. # This is temporary to not lose information param_descriptions[param_name] = f"({param_type}) {param.description}" # logger.info(f"Arguments for {self.name}: {param_type_hints}") # Get JSON schema for parameters only parameters = get_json_schema( type_hints=param_type_hints, param_descriptions=param_descriptions, strict=strict ) # If strict=True mark all fields as required # See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas#all-fields-must-be-required if strict: parameters["required"] = [name for name in parameters["properties"] if name != "agent"] else: # Mark a field as required if it has no default value parameters["required"] = [ name for name, param in sig.parameters.items() if param.default == param.empty and name != "self" and name != "agent" ] # logger.debug(f"JSON schema for {self.name}: {parameters}") except Exception as e: logger.warning(f"Could not parse args for {self.name}: {e}", exc_info=True) self.description = self.description or get_entrypoint_docstring(self.entrypoint) if not params_set_by_user: self.parameters = parameters self.entrypoint = validate_call(self.entrypoint) def get_type_name(self, t: Type[T]): name = str(t) if "list" in name or "dict" in name: return name else: return t.__name__ def get_definition_for_prompt_dict(self) -> Optional[Dict[str, Any]]: """Returns a function definition that can be used in a prompt.""" if self.entrypoint is None: return None type_hints = get_type_hints(self.entrypoint) return_type = type_hints.get("return", None) returns = None if return_type is not None: returns = self.get_type_name(return_type) function_info = { "name": self.name, "description": self.description, "arguments": self.parameters.get("properties", {}), "returns": returns, } return function_info def get_definition_for_prompt(self) -> Optional[str]: """Returns a function definition that can be used in a prompt.""" import json function_info = self.get_definition_for_prompt_dict() if function_info is not None: return json.dumps(function_info, indent=2) return None class FunctionCall(BaseModel): """Model for Function Calls""" # The function to be called. function: Function # The arguments to call the function with. arguments: Optional[Dict[str, Any]] = None # The result of the function call. result: Optional[Any] = None # The ID of the function call. call_id: Optional[str] = None # Error while parsing arguments or running the function. error: Optional[str] = None def get_call_str(self) -> str: """Returns a string representation of the function call.""" if self.arguments is None: return f"{self.function.name}()" trimmed_arguments = {} for k, v in self.arguments.items(): if isinstance(v, str) and len(v) > 100: trimmed_arguments[k] = "..." else: trimmed_arguments[k] = v call_str = f"{self.function.name}({', '.join([f'{k}={v}' for k, v in trimmed_arguments.items()])})" return call_str def execute(self) -> bool: """Runs the function call. Returns True if the function call was successful, False otherwise. The result of the function call is stored in self.result. """ from inspect import signature if self.function.entrypoint is None: return False logger.debug(f"Running: {self.get_call_str()}") function_call_success = False # Execute pre-hook if it exists if self.function.pre_hook is not None: try: pre_hook_args = {} # Check if the pre-hook has and agent argument if "agent" in signature(self.function.pre_hook).parameters: pre_hook_args["agent"] = self.function._agent # Check if the pre-hook has an fc argument if "fc" in signature(self.function.pre_hook).parameters: pre_hook_args["fc"] = self self.function.pre_hook(**pre_hook_args) except ToolCallException as e: logger.debug(f"{e.__class__.__name__}: {e}") self.error = str(e) raise except Exception as e: logger.warning(f"Error in pre-hook callback: {e}") logger.exception(e) # Call the function with no arguments if none are provided. if self.arguments is None: try: entrypoint_args = {} # Check if the entrypoint has and agent argument if "agent" in signature(self.function.entrypoint).parameters: entrypoint_args["agent"] = self.function._agent # Check if the entrypoint has an fc argument if "fc" in signature(self.function.entrypoint).parameters: entrypoint_args["fc"] = self self.result = self.function.entrypoint(**entrypoint_args) function_call_success = True except ToolCallException as e: logger.debug(f"{e.__class__.__name__}: {e}") self.error = str(e) raise except Exception as e: logger.warning(f"Could not run function {self.get_call_str()}") logger.exception(e) self.error = str(e) return function_call_success else: try: entrypoint_args = {} # Check if the entrypoint has and agent argument if "agent" in signature(self.function.entrypoint).parameters: entrypoint_args["agent"] = self.function._agent # Check if the entrypoint has an fc argument if "fc" in signature(self.function.entrypoint).parameters: entrypoint_args["fc"] = self self.result = self.function.entrypoint(**entrypoint_args, **self.arguments) function_call_success = True except ToolCallException as e: logger.debug(f"{e.__class__.__name__}: {e}") self.error = str(e) raise except Exception as e: logger.warning(f"Could not run function {self.get_call_str()}") logger.exception(e) self.error = str(e) return function_call_success # Execute post-hook if it exists if self.function.post_hook is not None: try: post_hook_args = {} # Check if the post-hook has and agent argument if "agent" in signature(self.function.post_hook).parameters: post_hook_args["agent"] = self.function._agent # Check if the post-hook has an fc argument if "fc" in signature(self.function.post_hook).parameters: post_hook_args["fc"] = self self.function.post_hook(**post_hook_args) except ToolCallException as e: logger.debug(f"{e.__class__.__name__}: {e}") self.error = str(e) raise except Exception as e: logger.warning(f"Error in post-hook callback: {e}") logger.exception(e) return function_call_success