diff --git a/phi/ai/phi_ai.py b/phi/ai/phi_ai.py index 502c92c14..a86c605d8 100644 --- a/phi/ai/phi_ai.py +++ b/phi/ai/phi_ai.py @@ -13,9 +13,10 @@ from phi.cli.config import PhiCliConfig from phi.cli.console import console from phi.cli.settings import phi_cli_settings -from phi.llm.schemas import Function, Message, FunctionCall -from phi.llm.function.shell import ShellScriptsRegistry -from phi.llm.function.phi_commands import PhiCommandsRegistry +from phi.llm.schemas import Message +from phi.tool.function import Function, FunctionCall +from phi.tool.shell import ShellTool +from phi.tool.phi import PhiTool from phi.workspace.config import WorkspaceConfig from phi.utils.log import logger from phi.utils.functions import get_function_call @@ -41,7 +42,7 @@ def __init__( _active_workspace = _phi_config.get_active_ws_config() self.conversation_db: Optional[List[Dict[str, Any]]] = None - self.functions: Dict[str, Function] = {**ShellScriptsRegistry().functions, **PhiCommandsRegistry().functions} + self.functions: Dict[str, Function] = {**ShellTool().functions, **PhiTool().functions} _conversation_id = None _conversation_history = None @@ -170,7 +171,7 @@ def run_function_stream(self, function_call: Dict[str, Any]) -> Iterator[str]: yield f"Running: {function_call_obj.get_call_str()}\n\n" function_call_timer = Timer() function_call_timer.start() - function_call_obj.run() + function_call_obj.execute() function_call_timer.stop() function_call_message = Message( role="function", @@ -213,7 +214,7 @@ def run_function(self, function_call: Dict[str, Any]) -> str: function_run_response = f"Running: {function_call_obj.get_call_str()}\n\n" function_call_timer = Timer() function_call_timer.start() - function_call_obj.run() + function_call_obj.execute() function_call_timer.stop() function_call_message = Message( role="function", diff --git a/phi/api/ai.py b/phi/api/ai.py index e1dae5e1d..99b950e2f 100644 --- a/phi/api/ai.py +++ b/phi/api/ai.py @@ -10,7 +10,8 @@ ConversationCreateResponse, ) from phi.api.schemas.user import UserSchema -from phi.llm.schemas import Function, Message +from phi.llm.schemas import Message +from phi.tool.function import Function from phi.utils.log import logger diff --git a/phi/assistant/assistant.py b/phi/assistant/assistant.py index 29ca74d59..8f550feeb 100644 --- a/phi/assistant/assistant.py +++ b/phi/assistant/assistant.py @@ -1,16 +1,16 @@ +import json from typing import List, Any, Optional, Dict, Union, Callable from pydantic import BaseModel, ConfigDict, field_validator, model_validator from phi.assistant.file import File -from phi.assistant.tool import Tool -from phi.assistant.tool.registry import ToolRegistry from phi.assistant.row import AssistantRow -from phi.assistant.function import Function, FunctionCall from phi.assistant.storage import AssistantStorage from phi.assistant.exceptions import AssistantIdNotSet +from phi.tool import Tool +from phi.tool.registry import ToolRegistry +from phi.tool.function import Function from phi.knowledge.base import KnowledgeBase - from phi.utils.log import logger, set_log_level_to_debug try: @@ -43,8 +43,9 @@ class Assistant(BaseModel): # A list of tool enabled on the assistant. There can be a maximum of 128 tools per assistant. # Tools can be of types code_interpreter, retrieval, or function. tools: Optional[List[Union[Tool, Dict, Callable, ToolRegistry]]] = None - # Functions the Assistant may call. - _function_map: Optional[Dict[str, Function]] = None + # -*- Functions available to the Assistant to call + # Functions provided from the tools. Note: These are not sent to the LLM API. + functions: Optional[Dict[str, Function]] = None # -*- Assistant Files # A list of file IDs attached to this assistant. @@ -94,24 +95,19 @@ def set_log_level(cls, v: bool) -> bool: def client(self) -> OpenAI: return self.openai or OpenAI() - def add_function(self, f: Function) -> None: - if self._function_map is None: - self._function_map = {} - self._function_map[f.name] = f - logger.debug(f"Added function {f.name} to Assistant") - @model_validator(mode="after") - def add_functions_to_assistant(self) -> "Assistant": + def extract_functions_from_tools(self) -> "Assistant": if self.tools is not None: for tool in self.tools: - if callable(tool): - f = Function.from_callable(tool) - self.add_function(f) - elif isinstance(tool, ToolRegistry): - if self._function_map is None: - self._function_map = {} - self._function_map.update(tool.functions) + if self.functions is None: + self.functions = {} + if isinstance(tool, ToolRegistry): + self.functions.update(tool.functions) logger.debug(f"Tools from {tool.name} added to Assistant.") + elif callable(tool): + f = Function.from_callable(tool) + self.functions[f.name] = f + logger.debug(f"Added function {f.name} to Assistant") return self def load_from_storage(self): @@ -124,6 +120,24 @@ def load_from_openai(self, openai_assistant: OpenAIAssistant): self.file_ids = openai_assistant.file_ids self.openai_assistant = openai_assistant + def get_tools_for_api(self) -> Optional[List[Dict[str, Any]]]: + if self.tools is None: + return None + + tools_for_api = [] + for tool in self.tools: + if isinstance(tool, Tool): + tools_for_api.append(tool.to_dict()) + elif isinstance(tool, dict): + tools_for_api.append(tool) + elif callable(tool): + func = Function.from_callable(tool) + tools_for_api.append({"type": "function", "function": func.to_dict()}) + elif isinstance(tool, ToolRegistry): + for _f in tool.functions.values(): + tools_for_api.append({"type": "function", "function": _f.to_dict()}) + return tools_for_api + def create(self) -> "Assistant": request_body: Dict[str, Any] = {} if self.name is not None: @@ -133,19 +147,7 @@ def create(self) -> "Assistant": if self.instructions is not None: request_body["instructions"] = self.instructions if self.tools is not None: - _tools = [] - for _tool in self.tools: - if isinstance(_tool, Tool): - _tools.append(_tool.to_dict()) - elif isinstance(_tool, dict): - _tools.append(_tool) - elif callable(_tool): - func = Function.from_callable(_tool) - _tools.append({"type": "function", "function": func.to_dict()}) - elif isinstance(_tool, ToolRegistry): - for _f in _tool.functions.values(): - _tools.append({"type": "function", "function": _f.to_dict()}) - request_body["tools"] = _tools + request_body["tools"] = self.get_tools_for_api() if self.file_ids is not None or self.files is not None: _file_ids = self.file_ids or [] if self.files is not None: @@ -208,19 +210,7 @@ def update(self) -> "Assistant": if self.instructions is not None: request_body["instructions"] = self.instructions if self.tools is not None: - _tools = [] - for _tool in self.tools: - if isinstance(_tool, Tool): - _tools.append(_tool.to_dict()) - elif isinstance(_tool, dict): - _tools.append(_tool) - elif callable(_tool): - func = Function.from_callable(_tool) - _tools.append({"type": "function", "function": func.to_dict()}) - elif isinstance(_tool, ToolRegistry): - for _f in _tool.functions.values(): - _tools.append({"type": "function", "function": _f.to_dict()}) - request_body["tools"] = _tools + request_body["tools"] = self.get_tools_for_api() if self.file_ids is not None or self.files is not None: _file_ids = self.file_ids or [] if self.files is not None: @@ -286,61 +276,4 @@ def pprint(self): pprint(self.to_dict()) def __str__(self) -> str: - import json - return json.dumps(self.to_dict(), indent=4) - - def get_function_call(self, name: str, arguments: Optional[str] = None) -> Optional[FunctionCall]: - import json - - logger.debug(f"Getting function {name}. Args: {arguments}") - if self._function_map is None: - return None - - function_to_call: Optional[Function] = None - if name in self._function_map: - function_to_call = self._function_map[name] - if function_to_call is None: - logger.error(f"Function {name} not found") - return None - - function_call = FunctionCall(function=function_to_call) - if arguments is not None and arguments != "": - try: - if "None" in arguments: - arguments = arguments.replace("None", "null") - if "True" in arguments: - arguments = arguments.replace("True", "true") - if "False" in arguments: - arguments = arguments.replace("False", "false") - _arguments = json.loads(arguments) - except Exception as e: - logger.error(f"Unable to decode function arguments {arguments}: {e}") - return None - - if not isinstance(_arguments, dict): - logger.error(f"Function arguments {arguments} is not a valid JSON object") - return None - - try: - clean_arguments: Dict[str, Any] = {} - for k, v in _arguments.items(): - if isinstance(v, str): - _v = v.strip().lower() - if _v in ("none", "null"): - clean_arguments[k] = None - elif _v == "true": - clean_arguments[k] = True - elif _v == "false": - clean_arguments[k] = False - else: - clean_arguments[k] = v.strip() - else: - clean_arguments[k] = v - - function_call.arguments = clean_arguments - except Exception as e: - logger.error(f"Unable to parse function arguments {arguments}: {e}") - return None - - return function_call diff --git a/phi/assistant/run.py b/phi/assistant/run.py index 3138dc922..c0f91675a 100644 --- a/phi/assistant/run.py +++ b/phi/assistant/run.py @@ -3,11 +3,12 @@ from pydantic import BaseModel, ConfigDict, model_validator -from phi.assistant.tool import Tool -from phi.assistant.tool.registry import ToolRegistry -from phi.assistant.function import Function from phi.assistant.assistant import Assistant from phi.assistant.exceptions import ThreadIdNotSet, AssistantIdNotSet, RunIdNotSet +from phi.tool import Tool +from phi.tool.registry import ToolRegistry +from phi.tool.function import Function +from phi.utils.functions import get_function_call from phi.utils.log import logger try: @@ -300,8 +301,10 @@ def run( tool_outputs = [] for tool_call in tool_calls: if tool_call.type == "function": - function_call = self.assistant.get_function_call( - name=tool_call.function.name, arguments=tool_call.function.arguments + function_call = get_function_call( + name=tool_call.function.name, + arguments=tool_call.function.arguments, + functions=self.assistant.functions, ) if function_call is None: logger.error(f"Function {tool_call.function.name} not found") diff --git a/phi/assistant/tool/__init__.py b/phi/assistant/tool/__init__.py deleted file mode 100644 index 01bde82b3..000000000 --- a/phi/assistant/tool/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from phi.assistant.tool.tool import Tool diff --git a/phi/conversation/conversation.py b/phi/conversation/conversation.py index 7690c1e56..30964f0af 100644 --- a/phi/conversation/conversation.py +++ b/phi/conversation/conversation.py @@ -12,9 +12,9 @@ from phi.llm.base import LLM from phi.llm.openai import OpenAIChat from phi.llm.schemas import Message, References -from phi.llm.agent.base import BaseAgent -from phi.llm.function.registry import FunctionRegistry from phi.llm.task.llm_task import LLMTask +from phi.tool.tool import Tool +from phi.tool.registry import ToolRegistry from phi.utils.format_str import remove_indent from phi.utils.log import logger, set_log_level_to_debug from phi.utils.timer import Timer @@ -22,9 +22,7 @@ class Conversation(BaseModel): # -*- LLM settings - # LLM to use for this conversation llm: LLM = OpenAIChat() - # LLM Introduction # Add an introduction (from the LLM) to the chat history introduction: Optional[str] = None @@ -75,19 +73,23 @@ class Conversation(BaseModel): # -*- Enable Function Calls # Makes the conversation Autonomous by letting the LLM call functions to achieve tasks. function_calls: bool = False - # Add a list of default functions to the LLM + # Add default functions to the LLM when function_calls is True. default_functions: bool = True # Show function calls in LLM messages. show_function_calls: bool = False - # A list of functions to add to the LLM. - functions: Optional[List[Callable]] = None - # A list of function registries to add to the LLM. - function_registries: Optional[List[FunctionRegistry]] = None - # -*- Agents - # Add a list of agents to the LLM - # function_calls must be True for agents to be added to the LLM - agents: Optional[List[BaseAgent]] = None + # -*- Conversation Tools + # A list of tools provided to the LLM. + # Currently, only functions are supported as a tool. + # Use this to provide a list of functions the model may generate JSON inputs for. + tools: Optional[List[Union[Tool, Dict, Callable, ToolRegistry]]] = None + # Controls which (if any) function is called by the model. + # "none" means the model will not call a function and instead generates a message. + # "auto" means the model can pick between generating a message or calling a function. + # Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} + # forces the model to call that function. + # "none" is the default when no functions are present. "auto" is the default if functions are present. + tool_choice: Optional[Union[str, Dict[str, Any]]] = None # -*- Tasks # Generate a response using tasks instead of a prompt @@ -96,9 +98,9 @@ class Conversation(BaseModel): # # -*- Prompt Settings # - # -*- System prompt: provide the system prompt as a string or using a function + # -*- System prompt: provide the system prompt as a string system_prompt: Optional[str] = None - # Function to build the system prompt. + # -*- System prompt function: provide the system prompt as a function # This function is provided the conversation as an argument # and should return the system_prompt as a string. # Signature: @@ -108,10 +110,10 @@ class Conversation(BaseModel): # If True, the conversation provides a default system prompt use_default_system_prompt: bool = True - # -*- User prompt: provide the user prompt as a string or using a function + # -*- User prompt: provide the user prompt as a string # Note: this will ignore the message provided to the chat function user_prompt: Optional[Union[List[Dict], str]] = None - # Function to build the user prompt. + # -*- User prompt function: provide the user prompt as a function. # This function is provided the conversation and the user message as arguments # and should return the user_prompt as a Union[List[Dict], str]. # If add_references_to_prompt is True, then references are also provided as an argument. @@ -141,7 +143,7 @@ class Conversation(BaseModel): # ... chat_history_function: Optional[Callable[..., Optional[str]]] = None - # -*- Latest LLM response + # -*- Latest LLM response i.e. the final output of this conversation output: Optional[Any] = None # If True, show debug logs @@ -163,63 +165,27 @@ def set_log_level(cls, v: bool) -> bool: return v @model_validator(mode="after") - def add_functions_to_llm(self) -> "Conversation": - update_tool_choice = False - - # Add functions from self.functions - if self.functions is not None: - update_tool_choice = True - for func in self.functions: - self.llm.add_function(func) - - # Add functions from registries - if self.function_registries is not None: - update_tool_choice = True - for registry in self.function_registries: - self.llm.add_function_registry(registry) - - if self.function_calls: - update_tool_choice = True - if self.default_functions: - default_func_list: List[Callable] = [ - self.get_last_n_chats, - self.search_knowledge_base, - ] - for func in default_func_list: - self.llm.add_function(func) - - # Set function_call/tool_choice to auto if it is not set - if update_tool_choice: - # Set function call to auto if it is not set - if self.llm.function_call is None: - self.llm.function_call = "auto" - # Set tool_choice to auto if it is not set - if self.llm.tool_choice is None: - self.llm.tool_choice = "auto" + def add_tools_to_llm(self) -> "Conversation": + if self.tools is not None: + for tool in self.tools: + self.llm.add_tool(tool) + + if self.function_calls and self.default_functions: + default_func_list: List[Callable] = [ + self.get_last_n_chats, + self.search_knowledge_base, + ] + for func in default_func_list: + self.llm.add_tool(func) # Set show_function_calls if it is not set on the llm - if self.llm.show_function_calls is None: + if self.llm.show_function_calls is None and self.show_function_calls is not None: self.llm.show_function_calls = self.show_function_calls - return self + # Set tool_choice to auto if it is not set on the llm + if self.llm.tool_choice is None and self.tool_choice is not None: + self.llm.tool_choice = self.tool_choice - @model_validator(mode="after") - def add_agents_to_llm(self) -> "Conversation": - if self.agents is not None and len(self.agents) > 0: - for agent in self.agents: - self.llm.add_agent(agent) - - # Set function_call to auto if it is not set - if self.llm.function_call is None: - self.llm.function_call = "auto" - - # Set tool_choice to auto if it is not set - if self.llm.tool_choice is None: - self.llm.tool_choice = "auto" - - # Set show_function_calls if it is not set on the llm - if self.llm.show_function_calls is None: - self.llm.show_function_calls = self.show_function_calls return self def to_database_row(self) -> ConversationRow: @@ -264,16 +230,6 @@ def from_database_row(self, row: ConversationRow): except Exception as e: logger.warning(f"Failed to load llm metrics: {e}") - # # Update llm functions from the database - # llm_functions_from_db = row.llm.get("functions") - # if llm_functions_from_db is not None and isinstance(llm_functions_from_db, dict): - # try: - # for k, v in llm_functions_from_db.items(): - # _llm_function = Function(**v) - # self.llm.add_function_schema(func=_llm_function, if_not_exists=True) - # except Exception as e: - # logger.error(f"Failed to load llm functions: {e}") - # Update conversation memory from the ConversationRow if row.memory is not None: try: diff --git a/phi/llm/agent/__init__.py b/phi/llm/agent/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/phi/llm/agent/arxiv.py b/phi/llm/agent/arxiv.py deleted file mode 100644 index e7b2660bb..000000000 --- a/phi/llm/agent/arxiv.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.arxiv import ArxivKnowledgeBase -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class ArxivAgent(BaseAgent): - def __init__(self, knowledge_base: Optional[ArxivKnowledgeBase] = None): - super().__init__(name="arxiv_agent") - self.knowledge_base: Optional[ArxivKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, ArxivKnowledgeBase): - self.register(self.search_arxiv_and_update_knowledge_base) - else: - self.register(self.search_arxiv) - - def search_arxiv_and_update_knowledge_base(self, topic: str) -> str: - """This function searches arXiv for a topic, adds the results to the knowledge base and returns them. - - USE THIS FUNCTION TO GET INFORMATION WHICH DOES NOT EXIST. - - :param topic: The topic to search arXiv and add to knowledge base. - :return: Relevant documents from arXiv knowledge base. - """ - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {topic}") - self.knowledge_base.queries.append(topic) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - logger.debug(f"Searching knowledge base: {topic}") - relevant_docs: List[Document] = self.knowledge_base.search(query=topic) - return json.dumps([doc.to_dict() for doc in relevant_docs]) - - def search_arxiv(self, query: str, max_results: int = 5) -> str: - """ - Searches arXiv for a query. - - :param query: The query to search for. - :param max_results: The maximum number of results to return. - :return: Relevant documents from arXiv. - """ - from phi.document.reader.arxiv import ArxivReader - - arxiv = ArxivReader(max_results=max_results) - - logger.debug(f"Searching arxiv for: {query}") - relevant_docs: List[Document] = arxiv.read(query=query) - return json.dumps([doc.to_dict() for doc in relevant_docs]) diff --git a/phi/llm/agent/base.py b/phi/llm/agent/base.py deleted file mode 100644 index 27310a3af..000000000 --- a/phi/llm/agent/base.py +++ /dev/null @@ -1,27 +0,0 @@ -from collections import OrderedDict -from typing import Callable, Dict - -from phi.llm.schemas import Function -from phi.utils.log import logger - - -class BaseAgent: - def __init__(self, name: str = "base_agent"): - self.name: str = name - self.functions: Dict[str, Function] = OrderedDict() - - def register(self, function: Callable): - try: - f = Function.from_callable(function) - self.functions[f.name] = f - logger.debug(f"Function: {f.name} registered with {self.name}") - logger.debug(f"Json Schema: {f.to_dict()}") - except Exception as e: - logger.warning(f"Failed to create Function for: {function.__name__}") - raise e - - def __repr__(self): - return f"<{self.__class__.__name__} name={self.name} functions={list(self.functions.keys())}>" - - def __str__(self): - return self.__repr__() diff --git a/phi/llm/agent/duckdb.py b/phi/llm/agent/duckdb.py deleted file mode 100644 index 9f0abe157..000000000 --- a/phi/llm/agent/duckdb.py +++ /dev/null @@ -1,339 +0,0 @@ -from typing import Optional, Tuple - -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - -try: - import duckdb -except ImportError: - raise ImportError("`duckdb` not installed. Please install it using `pip install duckdb`.") - - -class DuckDbAgent(BaseAgent): - def __init__( - self, - db_path: str = ":memory:", - s3_region: str = "us-east-1", - duckdb_connection: Optional[duckdb.DuckDBPyConnection] = None, - ): - super().__init__(name="duckdb_registry") - - self.db_path: str = db_path - self.s3_region: str = s3_region - self._duckdb_connection: Optional[duckdb.DuckDBPyConnection] = duckdb_connection - - self.register(self.run_duckdb_query) - self.register(self.show_tables) - self.register(self.describe_table) - self.register(self.inspect_query) - self.register(self.describe_table_or_view) - self.register(self.export_table_as) - self.register(self.summarize_table) - self.register(self.create_fts_index) - self.register(self.full_text_search) - - @property - def duckdb_connection(self) -> duckdb.DuckDBPyConnection: - """ - Returns the duckdb connection - - :return duckdb.DuckDBPyConnection: duckdb connection - """ - if self._duckdb_connection is None: - self._duckdb_connection = duckdb.connect(self.db_path) - try: - self._duckdb_connection.sql("INSTALL httpfs;") - self._duckdb_connection.sql("LOAD httpfs;") - self._duckdb_connection.sql(f"SET s3_region='{self.s3_region}';") - except Exception as e: - logger.exception(e) - logger.warning("Failed to install httpfs extension. Only local files will be supported") - - return self._duckdb_connection - - def run_duckdb_query(self, query: str) -> str: - """Function to run SQL queries against a duckdb database - - :param query: SQL query to run - :return: Result of the query - """ - - # -*- Format the SQL Query - # Remove backticks - formatted_sql = query.replace("`", "") - # If there are multiple statements, only run the first one - formatted_sql = formatted_sql.split(";")[0] - - try: - logger.debug(f"Running query: {formatted_sql}") - - query_result = self.duckdb_connection.sql(formatted_sql) - result_output = "No output" - if query_result is not None: - try: - results_as_python_objects = query_result.fetchall() - result_rows = [] - for row in results_as_python_objects: - if len(row) == 1: - result_rows.append(str(row[0])) - else: - result_rows.append(",".join(str(x) for x in row)) - - result_data = "\n".join(result_rows) - result_output = ",".join(query_result.columns) + "\n" + result_data - except AttributeError: - result_output = str(query_result) - - logger.debug(f"Query result: {result_output}") - return result_output - except duckdb.ProgrammingError as e: - return str(e) - except duckdb.Error as e: - return str(e) - except Exception as e: - return str(e) - - def show_tables(self) -> str: - """Function to show tables in the database - - :return: List of tables in the database - """ - stmt = "SHOW TABLES;" - tables = self.run_duckdb_query(stmt) - logger.debug(f"Tables: {tables}") - return tables - - def describe_table(self, table: str) -> str: - """Function to describe a table - - :param table: Table to describe - :return: Description of the table - """ - stmt = f"DESCRIBE {table};" - table_description = self.run_duckdb_query(stmt) - - logger.debug(f"Table description: {table_description}") - return f"{table}\n{table_description}" - - def summarize_table(self, table: str) -> str: - """Function to summarize the contents of a table - - :param table: Table to describe - :return: Description of the table - """ - stmt = f"SUMMARIZE SELECT * FROM {table};" - table_description = self.run_duckdb_query(stmt) - - logger.debug(f"Table description: {table_description}") - return f"{table}\n{table_description}" - - def inspect_query(self, query: str) -> str: - """Function to inspect a query and return the query plan. Always inspect your query before running them. - - :param query: Query to inspect - :return: Qeury plan - """ - stmt = f"explain {query};" - explain_plan = self.run_duckdb_query(stmt) - - logger.debug(f"Explain plan: {explain_plan}") - return explain_plan - - def describe_table_or_view(self, table: str): - """Function to describe a table or view - - :param table: Table or view to describe - :return: Description of the table or view - """ - stmt = f"select column_name, data_type from information_schema.columns where table_name='{table}';" - table_description = self.run_duckdb_query(stmt) - - logger.debug(f"Table description: {table_description}") - return f"{table}\n{table_description}" - - def load_local_path_to_table(self, path: str, table_name: Optional[str] = None) -> Tuple[str, str]: - """Load a local file into duckdb - - :param path: Path to load - :param table_name: Optional table name to use - :return: Table name, SQL statement used to load the file - """ - import os - - logger.debug(f"Loading {path} into duckdb") - - if table_name is None: - # Get the file name from the s3 path - file_name = path.split("/")[-1] - # Get the file name without extension from the s3 path - table_name, extension = os.path.splitext(file_name) - # If the table_name isn't a valid SQL identifier, we'll need to use something else - table_name = table_name.replace("-", "_").replace(".", "_").replace(" ", "_").replace("/", "_") - - create_statement = f"CREATE OR REPLACE TABLE '{table_name}' AS SELECT * FROM '{path}';" - self.run_duckdb_query(create_statement) - - logger.debug(f"Loaded {path} into duckdb as {table_name}") - # self.run_duckdb_query(f"SELECT * from {table_name};") - return table_name, create_statement - - def load_local_csv_to_table( - self, path: str, table_name: Optional[str] = None, delimiter: Optional[str] = None - ) -> Tuple[str, str]: - """Load a local CSV file into duckdb - - :param path: Path to load - :param table_name: Optional table name to use - :param delimiter: Optional delimiter to use - :return: Table name, SQL statement used to load the file - """ - import os - - logger.debug(f"Loading {path} into duckdb") - - if table_name is None: - # Get the file name from the s3 path - file_name = path.split("/")[-1] - # Get the file name without extension from the s3 path - table_name, extension = os.path.splitext(file_name) - # If the table_name isn't a valid SQL identifier, we'll need to use something else - table_name = table_name.replace("-", "_").replace(".", "_").replace(" ", "_").replace("/", "_") - - select_statement = f"SELECT * FROM read_csv('{path}'" - if delimiter is not None: - select_statement += f", delim='{delimiter}')" - else: - select_statement += ")" - - create_statement = f"CREATE OR REPLACE TABLE '{table_name}' AS {select_statement};" - self.run_duckdb_query(create_statement) - - logger.debug(f"Loaded CSV {path} into duckdb as {table_name}") - # self.run_duckdb_query(f"SELECT * from {table_name};") - return table_name, create_statement - - def load_s3_path_to_table(self, s3_path: str, table_name: Optional[str] = None) -> Tuple[str, str]: - """Load a file from S3 into duckdb - - :param s3_path: S3 path to load - :param table_name: Optional table name to use - :return: Table name, SQL statement used to load the file - """ - import os - - logger.debug(f"Loading {s3_path} into duckdb") - - if table_name is None: - # Get the file name from the s3 path - file_name = s3_path.split("/")[-1] - # Get the file name without extension from the s3 path - table_name, extension = os.path.splitext(file_name) - # If the table_name isn't a valid SQL identifier, we'll need to use something else - table_name = table_name.replace("-", "_").replace(".", "_").replace(" ", "_").replace("/", "_") - - create_statement = f"CREATE OR REPLACE TABLE '{table_name}' AS SELECT * FROM '{s3_path}';" - self.run_duckdb_query(create_statement) - - logger.debug(f"Loaded {s3_path} into duckdb as {table_name}") - # self.run_duckdb_query(f"SELECT * from {table_name};") - return table_name, create_statement - - def load_s3_csv_to_table( - self, s3_path: str, table_name: Optional[str] = None, delimiter: Optional[str] = None - ) -> Tuple[str, str]: - """Load a CSV file from S3 into duckdb - - :param s3_path: S3 path to load - :param table_name: Optional table name to use - :return: Table name, SQL statement used to load the file - """ - import os - - logger.debug(f"Loading {s3_path} into duckdb") - - if table_name is None: - # Get the file name from the s3 path - file_name = s3_path.split("/")[-1] - # Get the file name without extension from the s3 path - table_name, extension = os.path.splitext(file_name) - # If the table_name isn't a valid SQL identifier, we'll need to use something else - table_name = table_name.replace("-", "_").replace(".", "_").replace(" ", "_").replace("/", "_") - - select_statement = f"SELECT * FROM read_csv('{s3_path}'" - if delimiter is not None: - select_statement += f", delim='{delimiter}')" - else: - select_statement += ")" - - create_statement = f"CREATE OR REPLACE TABLE '{table_name}' AS {select_statement};" - self.run_duckdb_query(create_statement) - - logger.debug(f"Loaded CSV {s3_path} into duckdb as {table_name}") - # self.run_duckdb_query(f"SELECT * from {table_name};") - return table_name, create_statement - - def export_table_as(self, table_name: str, format: Optional[str] = "PARQUET", path: Optional[str] = None) -> str: - """Save a table to a desired format - The function will use the default format as parquet - If the path is provided, the table will be exported to that path, example s3 - - :param table_name: Table to export - :param format: Format to export to - :param path: Path to export to - :return: None - """ - if format is None: - format = "PARQUET" - - logger.debug(f"Exporting Table {table_name} as {format.upper()} in the path {path}") - # self.run_duckdb_query(f"SELECT * from {table_name};") - if path is None: - path = f"{table_name}.{format}" - else: - path = f"{path}/{table_name}.{format}" - export_statement = f"COPY (SELECT * FROM {table_name}) TO '{path}' (FORMAT {format.upper()});" - result = self.run_duckdb_query(export_statement) - logger.debug(f"Exported {table_name} to {path}/{table_name}") - - return result - - def create_fts_index(self, table_name: str, unique_key: str, input_values: list[str]) -> str: - """Create a full text search index on a table - - :param table_name: Table to create the index on - :param unique_key: Unique key to use - :param input_values: Values to index - :return: None - """ - logger.debug(f"Creating FTS index on {table_name} for {input_values}") - self.run_duckdb_query("INSTALL fts;") - logger.debug("Installed FTS extension") - self.run_duckdb_query("LOAD fts;") - logger.debug("Loaded FTS extension") - - create_fts_index_statement = f"PRAGMA create_fts_index('{table_name}', '{unique_key}', '{input_values}');" - logger.debug(f"Running {create_fts_index_statement}") - result = self.run_duckdb_query(create_fts_index_statement) - logger.debug(f"Created FTS index on {table_name} for {input_values}") - - return result - - def full_text_search(self, table_name: str, unique_key: str, search_text: str) -> str: - """Full text Search in a table column for a specific text/keyword - - :param table_name: Table to search - :param unique_key: Unique key to use - :param search_text: Text to search - :return: None - """ - logger.debug(f"Running full_text_search for {search_text} in {table_name}") - search_text_statement = f"""SELECT fts_main_corpus.match_bm25({unique_key}, '{search_text}') AS score,* - FROM {table_name} - WHERE score IS NOT NULL - ORDER BY score;""" - - logger.debug(f"Running {search_text_statement}") - result = self.run_duckdb_query(search_text_statement) - logger.debug(f"Search results for {search_text} in {table_name}") - - return result diff --git a/phi/llm/agent/email.py b/phi/llm/agent/email.py deleted file mode 100644 index 1df6a7201..000000000 --- a/phi/llm/agent/email.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Optional - -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class EmailAgent(BaseAgent): - def __init__( - self, - receiver_email: Optional[str] = None, - sender_name: Optional[str] = None, - sender_email: Optional[str] = None, - sender_passkey: Optional[str] = None, - ): - super().__init__(name="email_agent") - self.receiver_email: Optional[str] = receiver_email - self.sender_name: Optional[str] = sender_name - self.sender_email: Optional[str] = sender_email - self.sender_passkey: Optional[str] = sender_passkey - self.register(self.email_user) - - def email_user(self, subject: str, body: str) -> str: - """Emails the user with the given subject and body. - - :param subject: The subject of the email. - :param body: The body of the email. - :return: "success" if the email was sent successfully, "error: [error message]" otherwise. - """ - try: - import smtplib - from email.message import EmailMessage - except ImportError: - logger.error("`smtplib` not installed") - raise - - if not self.receiver_email: - return "error: No receiver email provided" - if not self.sender_name: - return "error: No sender name provided" - if not self.sender_email: - return "error: No sender email provided" - if not self.sender_passkey: - return "error: No sender passkey provided" - - msg = EmailMessage() - msg["Subject"] = subject - msg["From"] = f"{self.sender_name} <{self.sender_email}>" - msg["To"] = self.receiver_email - msg.set_content(body) - - logger.info(f"Sending Email to {self.receiver_email}") - try: - with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp: - smtp.login(self.sender_email, self.sender_passkey) - smtp.send_message(msg) - except Exception as e: - logger.error(f"Error sending email: {e}") - return f"error: {e}" - return "email sent successfully" diff --git a/phi/llm/agent/google.py b/phi/llm/agent/google.py deleted file mode 100644 index 79f3ca7f3..000000000 --- a/phi/llm/agent/google.py +++ /dev/null @@ -1,18 +0,0 @@ -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class GoogleAgent(BaseAgent): - def __init__(self): - super().__init__(name="google_agent") - self.register(self.get_result_from_google) - - def get_result_from_google(self, query: str) -> str: - """Gets the result for a query from Google. - Use this function to find an answer when not available in the knowledge base. - - :param query: The query to search for. - :return: The result from Google. - """ - logger.info(f"Searching google for: {query}") - return "Sorry, this capability is not available yet." diff --git a/phi/llm/agent/phi.py b/phi/llm/agent/phi.py deleted file mode 100644 index 872e4d6ea..000000000 --- a/phi/llm/agent/phi.py +++ /dev/null @@ -1,116 +0,0 @@ -import uuid -from typing import Optional - -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class PhiAgent(BaseAgent): - def __init__(self): - super().__init__(name="phi_agent") - self.register(self.create_new_app) - self.register(self.start_user_workspace) - self.register(self.validate_phi_is_ready) - - def validate_phi_is_ready(self) -> bool: - """Validates that Phi is ready to run commands. - - :return: True if Phi is ready, False otherwise. - """ - # Check if docker is running - return True - - def create_new_app(self, template: str, workspace_name: str) -> str: - """Creates a new phidata workspace for a given application template. - Use this function when the user wants to create a new "llm-app", "api-app", "django-app", or "streamlit-app". - Remember to provide a name for the new workspace. - You can use the format: "template-name" + name of an interesting person (lowercase, no spaces). - - :param template: (required) The template to use for the new application. - One of: llm-app, api-app, django-app, streamlit-app - :param workspace_name: (required) The name of the workspace to create for the new application. - :return: Status of the function or next steps. - """ - from phi.workspace.operator import create_workspace, TEMPLATE_TO_NAME_MAP, WorkspaceStarterTemplate - - ws_template: Optional[WorkspaceStarterTemplate] = None - if template.lower() in WorkspaceStarterTemplate.__members__.values(): - ws_template = WorkspaceStarterTemplate(template) - - if ws_template is None: - return f"Error: Invalid template: {template}, must be one of: llm-app, api-app, django-app, streamlit-app" - - ws_dir_name: Optional[str] = workspace_name - if ws_dir_name is None: - # Get default_ws_name from template - default_ws_name: Optional[str] = TEMPLATE_TO_NAME_MAP.get(ws_template) - # Add a 2 digit random suffix to the default_ws_name - random_suffix = str(uuid.uuid4())[:2] - default_ws_name = f"{default_ws_name}-{random_suffix}" - - return ( - f"Ask the user for a name for the app directory with the default value: {default_ws_name}." - f"Ask the user to input YES or NO to use the default value." - ) - # # Ask user for workspace name if not provided - # ws_dir_name = Prompt.ask("Please provide a name for the app", default=default_ws_name, console=console) - - logger.info(f"Creating: {template} at {ws_dir_name}") - try: - create_successful = create_workspace(name=ws_dir_name, template=ws_template.value) - if create_successful: - return ( - f"Successfully created a {ws_template.value} at {ws_dir_name}. " - f"Ask the user if they want to start the app now." - ) - else: - return f"Error: Failed to create {template}" - except Exception as e: - return f"Error: {e}" - - def start_user_workspace(self, workspace_name: Optional[str] = None) -> str: - """Starts the workspace for a user. Use this function when the user wants to start a given workspace. - If the workspace name is not provided, the function will start the active workspace. - Otherwise, it will start the workspace with the given name. - - :param workspace_name: The name of the workspace to start - :return: Status of the function or next steps. - """ - from phi.cli.config import PhiCliConfig - from phi.infra.type import InfraType - from phi.workspace.config import WorkspaceConfig - from phi.workspace.operator import start_workspace - - phi_config: Optional[PhiCliConfig] = PhiCliConfig.from_saved_config() - if not phi_config: - return "Error: Phi not initialized. Please run `phi ai` again" - - workspace_config_to_start: Optional[WorkspaceConfig] = None - active_ws_config: Optional[WorkspaceConfig] = phi_config.get_active_ws_config() - - if workspace_name is None: - if active_ws_config is None: - return "Error: No active workspace found. Please create a workspace first." - workspace_config_to_start = active_ws_config - else: - workspace_config_by_name: Optional[WorkspaceConfig] = phi_config.get_ws_config_by_dir_name(workspace_name) - if workspace_config_by_name is None: - return f"Error: Could not find a workspace with name: {workspace_name}" - workspace_config_to_start = workspace_config_by_name - - # Set the active workspace to the workspace to start - if active_ws_config is not None and active_ws_config.ws_root_path != workspace_config_by_name.ws_root_path: - phi_config.set_active_ws_dir(workspace_config_by_name.ws_root_path) - active_ws_config = workspace_config_by_name - - try: - start_workspace( - phi_config=phi_config, - ws_config=workspace_config_to_start, - target_env="dev", - target_infra=InfraType.docker, - auto_confirm=True, - ) - return f"Successfully started workspace: {workspace_config_to_start.ws_root_path.stem}" - except Exception as e: - return f"Error: {e}" diff --git a/phi/llm/agent/pubmed.py b/phi/llm/agent/pubmed.py deleted file mode 100644 index 15c04e97b..000000000 --- a/phi/llm/agent/pubmed.py +++ /dev/null @@ -1,19 +0,0 @@ -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class PubMedAgent(BaseAgent): - def __init__(self): - super().__init__(name="pubmed_agent") - self.register(self.get_articles_from_pubmed) - - def get_articles_from_pubmed(self, query: str, num_articles: int = 2) -> str: - """Gets the abstract for articles related to a query from PubMed: a database of biomedical literature - Use this function to find information about a medical concept when not available in the knowledge base or Google - - :param query: The query to get related articles for. - :param num_articles: The number of articles to return. - :return: JSON string containing the articles - """ - logger.info(f"Searching Pubmed for: {query}") - return "Sorry, this capability is not available yet." diff --git a/phi/llm/agent/shell.py b/phi/llm/agent/shell.py deleted file mode 100644 index 4cb4dcc29..000000000 --- a/phi/llm/agent/shell.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List - -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class ShellAgent(BaseAgent): - def __init__(self): - super().__init__(name="shell_agent") - self.register(self.run_shell_command) - - def run_shell_command(self, args: List[str], tail: int = 100) -> str: - """Runs a shell command and returns the output or error. - - :param args: The command to run as a list of strings. - :param tail: The number of lines to return from the output. - :return: The output of the command. - """ - logger.info(f"Running shell command: {args}") - - import subprocess - - try: - result = subprocess.run(args, capture_output=True, text=True) - logger.debug(f"Result: {result}") - logger.debug(f"Return code: {result.returncode}") - if result.returncode != 0: - return f"Error: {result.stderr}" - - # return only the last n lines of the output - return "\n".join(result.stdout.split("\n")[-tail:]) - except Exception as e: - logger.warning(f"Failed to run shell command: {e}") - return f"Error: {e}" diff --git a/phi/llm/agent/website.py b/phi/llm/agent/website.py deleted file mode 100644 index 5620b0393..000000000 --- a/phi/llm/agent/website.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.website import WebsiteKnowledgeBase -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class WebsiteAgent(BaseAgent): - def __init__(self, knowledge_base: Optional[WebsiteKnowledgeBase] = None): - super().__init__(name="website_agent") - self.knowledge_base: Optional[WebsiteKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, WebsiteKnowledgeBase): - self.register(self.add_website_to_knowledge_base) - else: - self.register(self.read_website) - - def add_website_to_knowledge_base(self, url: str) -> str: - """This function adds a websites content to the knowledge base. - NOTE: The website must start with https:// and should be a valid website. - - USE THIS FUNCTION TO GET INFORMATION ABOUT PRODUCTS FROM THE INTERNET. - - :param url: The url of the website to add. - :return: 'Success' if the website was added to the knowledge base. - """ - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {url}") - self.knowledge_base.urls.append(url) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - return "Success" - - def read_website(self, url: str) -> str: - """This function reads a website and returns the content. - - :param url: The url of the website to read. - :return: Relevant documents from the website. - """ - from phi.document.reader.website import WebsiteReader - - website = WebsiteReader() - - logger.debug(f"Reading website: {url}") - relevant_docs: List[Document] = website.read(url=url) - return json.dumps([doc.to_dict() for doc in relevant_docs]) diff --git a/phi/llm/agent/wikipedia.py b/phi/llm/agent/wikipedia.py deleted file mode 100644 index ff2bb2377..000000000 --- a/phi/llm/agent/wikipedia.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.wikipedia import WikipediaKnowledgeBase -from phi.llm.agent.base import BaseAgent -from phi.utils.log import logger - - -class WikipediaAgent(BaseAgent): - def __init__(self, knowledge_base: Optional[WikipediaKnowledgeBase] = None): - super().__init__(name="wikipedia_agent") - self.knowledge_base: Optional[WikipediaKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, WikipediaKnowledgeBase): - self.register(self.search_wikipedia_and_update_knowledge_base) - else: - self.register(self.search_wikipedia) - - def search_wikipedia_and_update_knowledge_base(self, topic: str) -> str: - """This function searches wikipedia for a topic, adds the results to the knowledge base and returns them. - - USE THIS FUNCTION TO GET INFORMATION WHICH DOES NOT EXIST. - - :param topic: The topic to search Wikipedia and add to knowledge base. - :return: Relevant documents from Wikipedia knowledge base. - """ - - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {topic}") - self.knowledge_base.topics.append(topic) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - logger.debug(f"Searching knowledge base: {topic}") - relevant_docs: List[Document] = self.knowledge_base.search(query=topic) - return json.dumps([doc.to_dict() for doc in relevant_docs]) - - def search_wikipedia(self, query: str) -> str: - """Searches Wikipedia for a query. - - :param query: The query to search for. - :return: Relevant documents from wikipedia. - """ - try: - import wikipedia # noqa: F401 - except ImportError: - raise ImportError( - "The `wikipedia` package is not installed. " "Please install it via `pip install wikipedia`." - ) - - logger.info(f"Searching wikipedia for: {query}") - return json.dumps(Document(name=query, content=wikipedia.summary(query)).to_dict()) diff --git a/phi/llm/base.py b/phi/llm/base.py index 28864edcd..a3d217263 100644 --- a/phi/llm/base.py +++ b/phi/llm/base.py @@ -1,27 +1,26 @@ -import json from typing import List, Iterator, Optional, Dict, Any, Callable, Union from pydantic import BaseModel, ConfigDict -from phi.llm.schemas import Message, Function, FunctionCall -from phi.llm.agent.base import BaseAgent -from phi.llm.tool.base import BaseTool -from phi.llm.function.registry import FunctionRegistry +from phi.llm.schemas import Message +from phi.tool.tool import Tool +from phi.tool.registry import ToolRegistry +from phi.tool.function import Function, FunctionCall from phi.utils.log import logger class LLM(BaseModel): # ID of the model to use. model: str - # Name for this LLM (not used for api calls). + # Name for this LLM. Note: This is not sent to the LLM API. name: Optional[str] = None - # Metrics collected for this LLM (not used for api calls). + # Metrics collected for this LLM. Note: This is not sent to the LLM API. metrics: Dict[str, Any] = {} # A list of tools the model may call. # Currently, only functions are supported as a tool. - # Use this to provide a list of functions the model may generate JSON inputs for. - tools: Optional[List[BaseTool]] = None + # Always add tools using the add_tool() method. + tools: Optional[List[Union[Tool, Dict]]] = None # Controls which (if any) function is called by the model. # "none" means the model will not call a function and instead generates a message. # "auto" means the model can pick between generating a message or calling a function. @@ -29,20 +28,18 @@ class LLM(BaseModel): # forces the model to call that function. # "none" is the default when no functions are present. "auto" is the default if functions are present. tool_choice: Optional[Union[str, Dict[str, Any]]] = None - # A list of functions to add to the tools - functions: Optional[Dict[str, Function]] = None + # -*- Functions available to the LLM to call -*- + # Functions provided from the tools. Note: These are not sent to the LLM API. + functions: Optional[Dict[str, Function]] = None + # If True, runs function calls before sending back the response content. + run_function_calls: bool = True + # If True, shows function calls in the response. + show_function_calls: Optional[bool] = None # Maximum number of function calls allowed. - function_call_limit: int = 50 + function_call_limit: int = 100 # Stack of function calls. function_call_stack: Optional[List[FunctionCall]] = None - # If True, shows function calls in the response. - show_function_calls: Optional[bool] = None - # If True, runs function calls before sending back the response content. - run_function_calls: bool = True - - # NOTE: Deprecated in favor of tool_choice. - function_call: Optional[str] = None model_config = ConfigDict(arbitrary_types_allowed=True) @@ -75,86 +72,39 @@ def to_dict(self) -> Dict[str, Any]: _dict["function_call_limit"] = self.function_call_limit return _dict - def add_function(self, f: Callable) -> None: - func = Function.from_callable(f) - if self.functions is None: - self.functions = {} - self.functions[func.name] = func - logger.debug(f"Added function {func.name} to LLM.") - - def add_function_schema(self, func: Function, if_not_exists: bool = True) -> None: - if self.functions is None: - self.functions = {} - - if if_not_exists and func.name in self.functions: - return - - self.functions[func.name] = func - logger.debug(f"Added function {func.name} to LLM.") - - def add_function_registry(self, registry: FunctionRegistry) -> None: - if self.functions is None: - self.functions = {} - - self.functions.update(registry.functions) - logger.debug(f"Functions from {registry.name} added to LLM.") - - def add_agent(self, agent: BaseAgent) -> None: - if self.functions is None: - self.functions = {} - - self.functions.update(agent.functions) - logger.debug(f"Agent: {agent.name} added to LLM.") - - def get_function_call(self, name: str, arguments: Optional[str] = None) -> Optional[FunctionCall]: - logger.debug(f"Getting function {name}. Args: {arguments}") - if self.functions is None: - return None - - function_to_call: Optional[Function] = None - if name in self.functions: - function_to_call = self.functions[name] - if function_to_call is None: - logger.error(f"Function {name} not found") + def get_tools_for_api(self) -> Optional[List[Dict[str, Any]]]: + if self.tools is None: return None - function_call = FunctionCall(function=function_to_call) - if arguments is not None and arguments != "": - try: - if "None" in arguments: - arguments = arguments.replace("None", "null") - if "True" in arguments: - arguments = arguments.replace("True", "true") - if "False" in arguments: - arguments = arguments.replace("False", "false") - _arguments = json.loads(arguments) - except Exception as e: - logger.error(f"Unable to decode function arguments {arguments}: {e}") - return None - - if not isinstance(_arguments, dict): - logger.error(f"Function arguments {arguments} is not a valid JSON object") - return None - - try: - clean_arguments: Dict[str, Any] = {} - for k, v in _arguments.items(): - if isinstance(v, str): - _v = v.strip().lower() - if _v in ("none", "null"): - clean_arguments[k] = None - elif _v == "true": - clean_arguments[k] = True - elif _v == "false": - clean_arguments[k] = False - else: - clean_arguments[k] = v.strip() - else: - clean_arguments[k] = v - - function_call.arguments = clean_arguments - except Exception as e: - logger.error(f"Unable to parse function arguments {arguments}: {e}") - return None - - return function_call + tools_for_api = [] + for tool in self.tools: + if isinstance(tool, Tool): + tools_for_api.append(tool.to_dict()) + elif isinstance(tool, Dict): + tools_for_api.append(tool) + return tools_for_api + + def add_tool(self, tool: Union[Tool, Dict, Callable, ToolRegistry]) -> None: + if self.tools is None: + self.tools = [] + + # If the tool is a Tool or Dict, add it directly to the LLM + if isinstance(tool, Tool) or isinstance(tool, Dict): + self.tools.append(tool) + logger.debug(f"Added tool {tool} to LLM.") + + # If the tool is a Callable or ToolRegistry, add its functions to the LLM + if callable(tool) or isinstance(tool, ToolRegistry): + if self.functions is None: + self.functions = {} + + if isinstance(tool, ToolRegistry): + self.functions.update(tool.functions) + for func in tool.functions.values(): + self.tools.append({"type": "function", "function": func.to_dict()}) + logger.debug(f"Functions from {tool.name} added to LLM.") + elif callable(tool): + func = Function.from_callable(tool) + self.functions[func.name] = func + self.tools.append({"type": "function", "function": func.to_dict()}) + logger.debug(f"Added function {func.name} to LLM.") diff --git a/phi/llm/function/__init__.py b/phi/llm/function/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/phi/llm/function/arxiv.py b/phi/llm/function/arxiv.py deleted file mode 100644 index a540f4e64..000000000 --- a/phi/llm/function/arxiv.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.arxiv import ArxivKnowledgeBase -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class ArxivRegistry(FunctionRegistry): - def __init__(self, knowledge_base: Optional[ArxivKnowledgeBase] = None): - super().__init__(name="arxiv_registry") - self.knowledge_base: Optional[ArxivKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, ArxivKnowledgeBase): - self.register(self.search_arxiv_and_update_knowledge_base) - else: - self.register(self.search_arxiv) - - def search_arxiv_and_update_knowledge_base(self, topic: str) -> str: - """This function searches arXiv for a topic, adds the results to the knowledge base and returns them. - - USE THIS FUNCTION TO GET INFORMATION WHICH DOES NOT EXIST. - - :param topic: The topic to search arXiv and add to knowledge base. - :return: Relevant documents from arXiv knowledge base. - """ - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {topic}") - self.knowledge_base.queries.append(topic) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - logger.debug(f"Searching knowledge base: {topic}") - relevant_docs: List[Document] = self.knowledge_base.search(query=topic) - return json.dumps([doc.to_dict() for doc in relevant_docs]) - - def search_arxiv(self, query: str, max_results: int = 5) -> str: - """ - Searches arXiv for a query. - - :param query: The query to search for. - :param max_results: The maximum number of results to return. - :return: Relevant documents from arXiv. - """ - from phi.document.reader.arxiv import ArxivReader - - arxiv = ArxivReader(max_results=max_results) - - logger.debug(f"Searching arxiv for: {query}") - relevant_docs: List[Document] = arxiv.read(query=query) - return json.dumps([doc.to_dict() for doc in relevant_docs]) diff --git a/phi/llm/function/email.py b/phi/llm/function/email.py deleted file mode 100644 index 42d8d2b68..000000000 --- a/phi/llm/function/email.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Optional - -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class EmailRegistry(FunctionRegistry): - def __init__( - self, - receiver_email: Optional[str] = None, - sender_name: Optional[str] = None, - sender_email: Optional[str] = None, - sender_passkey: Optional[str] = None, - ): - super().__init__(name="email_registry") - self.receiver_email: Optional[str] = receiver_email - self.sender_name: Optional[str] = sender_name - self.sender_email: Optional[str] = sender_email - self.sender_passkey: Optional[str] = sender_passkey - self.register(self.email_user) - - def email_user(self, subject: str, body: str) -> str: - """Emails the user with the given subject and body. - - :param subject: The subject of the email. - :param body: The body of the email. - :return: "success" if the email was sent successfully, "error: [error message]" otherwise. - """ - try: - import smtplib - from email.message import EmailMessage - except ImportError: - logger.error("`smtplib` not installed") - raise - - if not self.receiver_email: - return "error: No receiver email provided" - if not self.sender_name: - return "error: No sender name provided" - if not self.sender_email: - return "error: No sender email provided" - if not self.sender_passkey: - return "error: No sender passkey provided" - - msg = EmailMessage() - msg["Subject"] = subject - msg["From"] = f"{self.sender_name} <{self.sender_email}>" - msg["To"] = self.receiver_email - msg.set_content(body) - - logger.info(f"Sending Email to {self.receiver_email}") - try: - with smtplib.SMTP_SSL("smtp.gmail.com", 465) as smtp: - smtp.login(self.sender_email, self.sender_passkey) - smtp.send_message(msg) - except Exception as e: - logger.error(f"Error sending email: {e}") - return f"error: {e}" - return "email sent successfully" diff --git a/phi/llm/function/google.py b/phi/llm/function/google.py deleted file mode 100644 index f66e6a996..000000000 --- a/phi/llm/function/google.py +++ /dev/null @@ -1,18 +0,0 @@ -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class GoogleRegistry(FunctionRegistry): - def __init__(self): - super().__init__(name="google_registry") - self.register(self.get_result_from_google) - - def get_result_from_google(self, query: str) -> str: - """Gets the result for a query from Google. - Use this function to find an answer when not available in the knowledge base. - - :param query: The query to search for. - :return: The result from Google. - """ - logger.info(f"Searching google for: {query}") - return "Sorry, this capability is not available yet." diff --git a/phi/llm/function/phi_commands.py b/phi/llm/function/phi_commands.py deleted file mode 100644 index ff2c9ec3f..000000000 --- a/phi/llm/function/phi_commands.py +++ /dev/null @@ -1,116 +0,0 @@ -import uuid -from typing import Optional - -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class PhiCommandsRegistry(FunctionRegistry): - def __init__(self): - super().__init__(name="phi_commands_registry") - self.register(self.create_new_app) - self.register(self.start_user_workspace) - self.register(self.validate_phi_is_ready) - - def validate_phi_is_ready(self) -> bool: - """Validates that Phi is ready to run commands. - - :return: True if Phi is ready, False otherwise. - """ - # Check if docker is running - return True - - def create_new_app(self, template: str, workspace_name: str) -> str: - """Creates a new phidata workspace for a given application template. - Use this function when the user wants to create a new "llm-app", "api-app", "django-app", or "streamlit-app". - Remember to provide a name for the new workspace. - You can use the format: "template-name" + name of an interesting person (lowercase, no spaces). - - :param template: (required) The template to use for the new application. - One of: llm-app, api-app, django-app, streamlit-app - :param workspace_name: (required) The name of the workspace to create for the new application. - :return: Status of the function or next steps. - """ - from phi.workspace.operator import create_workspace, TEMPLATE_TO_NAME_MAP, WorkspaceStarterTemplate - - ws_template: Optional[WorkspaceStarterTemplate] = None - if template.lower() in WorkspaceStarterTemplate.__members__.values(): - ws_template = WorkspaceStarterTemplate(template) - - if ws_template is None: - return f"Error: Invalid template: {template}, must be one of: llm-app, api-app, django-app, streamlit-app" - - ws_dir_name: Optional[str] = workspace_name - if ws_dir_name is None: - # Get default_ws_name from template - default_ws_name: Optional[str] = TEMPLATE_TO_NAME_MAP.get(ws_template) - # Add a 2 digit random suffix to the default_ws_name - random_suffix = str(uuid.uuid4())[:2] - default_ws_name = f"{default_ws_name}-{random_suffix}" - - return ( - f"Ask the user for a name for the app directory with the default value: {default_ws_name}." - f"Ask the user to input YES or NO to use the default value." - ) - # # Ask user for workspace name if not provided - # ws_dir_name = Prompt.ask("Please provide a name for the app", default=default_ws_name, console=console) - - logger.info(f"Creating: {template} at {ws_dir_name}") - try: - create_successful = create_workspace(name=ws_dir_name, template=ws_template.value) - if create_successful: - return ( - f"Successfully created a {ws_template.value} at {ws_dir_name}. " - f"Ask the user if they want to start the app now." - ) - else: - return f"Error: Failed to create {template}" - except Exception as e: - return f"Error: {e}" - - def start_user_workspace(self, workspace_name: Optional[str] = None) -> str: - """Starts the workspace for a user. Use this function when the user wants to start a given workspace. - If the workspace name is not provided, the function will start the active workspace. - Otherwise, it will start the workspace with the given name. - - :param workspace_name: The name of the workspace to start - :return: Status of the function or next steps. - """ - from phi.cli.config import PhiCliConfig - from phi.infra.type import InfraType - from phi.workspace.config import WorkspaceConfig - from phi.workspace.operator import start_workspace - - phi_config: Optional[PhiCliConfig] = PhiCliConfig.from_saved_config() - if not phi_config: - return "Error: Phi not initialized. Please run `phi ai` again" - - workspace_config_to_start: Optional[WorkspaceConfig] = None - active_ws_config: Optional[WorkspaceConfig] = phi_config.get_active_ws_config() - - if workspace_name is None: - if active_ws_config is None: - return "Error: No active workspace found. Please create a workspace first." - workspace_config_to_start = active_ws_config - else: - workspace_config_by_name: Optional[WorkspaceConfig] = phi_config.get_ws_config_by_dir_name(workspace_name) - if workspace_config_by_name is None: - return f"Error: Could not find a workspace with name: {workspace_name}" - workspace_config_to_start = workspace_config_by_name - - # Set the active workspace to the workspace to start - if active_ws_config is not None and active_ws_config.ws_root_path != workspace_config_by_name.ws_root_path: - phi_config.set_active_ws_dir(workspace_config_by_name.ws_root_path) - active_ws_config = workspace_config_by_name - - try: - start_workspace( - phi_config=phi_config, - ws_config=workspace_config_to_start, - target_env="dev", - target_infra=InfraType.docker, - auto_confirm=True, - ) - return f"Successfully started workspace: {workspace_config_to_start.ws_root_path.stem}" - except Exception as e: - return f"Error: {e}" diff --git a/phi/llm/function/pubmed.py b/phi/llm/function/pubmed.py deleted file mode 100644 index a28bbc8c9..000000000 --- a/phi/llm/function/pubmed.py +++ /dev/null @@ -1,19 +0,0 @@ -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class PubMedRegistry(FunctionRegistry): - def __init__(self): - super().__init__(name="pubmed_registry") - self.register(self.get_articles_from_pubmed) - - def get_articles_from_pubmed(self, query: str, num_articles: int = 2) -> str: - """Gets the abstract for articles related to a query from PubMed: a database of biomedical literature - Use this function to find information about a medical concept when not available in the knowledge base or Google - - :param query: The query to get related articles for. - :param num_articles: The number of articles to return. - :return: JSON string containing the articles - """ - logger.info(f"Searching Pubmed for: {query}") - return "Sorry, this capability is not available yet." diff --git a/phi/llm/function/registry.py b/phi/llm/function/registry.py deleted file mode 100644 index 7b2dac4f6..000000000 --- a/phi/llm/function/registry.py +++ /dev/null @@ -1,27 +0,0 @@ -from collections import OrderedDict -from typing import Callable, Dict - -from phi.llm.schemas import Function -from phi.utils.log import logger - - -class FunctionRegistry: - def __init__(self, name: str = "default_registry"): - self.name: str = name - self.functions: Dict[str, Function] = OrderedDict() - - def register(self, function: Callable): - try: - f = Function.from_callable(function) - self.functions[f.name] = f - logger.debug(f"Function: {f.name} registered with {self.name}") - logger.debug(f"Json Schema: {f.to_dict()}") - except Exception as e: - logger.warning(f"Failed to create Function for: {function.__name__}") - raise e - - def __repr__(self): - return f"<{self.__class__.__name__} name={self.name} functions={list(self.functions.keys())}>" - - def __str__(self): - return self.__repr__() diff --git a/phi/llm/function/shell.py b/phi/llm/function/shell.py deleted file mode 100644 index 9f1a42824..000000000 --- a/phi/llm/function/shell.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List - -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class ShellScriptsRegistry(FunctionRegistry): - def __init__(self): - super().__init__(name="shell_script_registry") - self.register(self.run_shell_command) - - def run_shell_command(self, args: List[str], tail: int = 100) -> str: - """Runs a shell command and returns the output or error. - - :param args: The command to run as a list of strings. - :param tail: The number of lines to return from the output. - :return: The output of the command. - """ - logger.info(f"Running shell command: {args}") - - import subprocess - - try: - result = subprocess.run(args, capture_output=True, text=True) - logger.debug(f"Result: {result}") - logger.debug(f"Return code: {result.returncode}") - if result.returncode != 0: - return f"Error: {result.stderr}" - - # return only the last n lines of the output - return "\n".join(result.stdout.split("\n")[-tail:]) - except Exception as e: - logger.warning(f"Failed to run shell command: {e}") - return f"Error: {e}" diff --git a/phi/llm/function/website.py b/phi/llm/function/website.py deleted file mode 100644 index 91a3963c8..000000000 --- a/phi/llm/function/website.py +++ /dev/null @@ -1,50 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.website import WebsiteKnowledgeBase -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class WebsiteRegistry(FunctionRegistry): - def __init__(self, knowledge_base: Optional[WebsiteKnowledgeBase] = None): - super().__init__(name="website_registry") - self.knowledge_base: Optional[WebsiteKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, WebsiteKnowledgeBase): - self.register(self.add_website_to_knowledge_base) - else: - self.register(self.read_website) - - def add_website_to_knowledge_base(self, url: str) -> str: - """This function adds a websites content to the knowledge base. - NOTE: The website must start wit http:// or https:// and should be a valid website. - - USE THIS FUNCTION TO GET INFORMATION ABOUT PRODUCTS FROM THE INTERNET. - - :param url: The url of the website to add. - :return: 'Success' if the website was added to the knowledge base. - """ - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {url}") - self.knowledge_base.urls.append(url) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - return "Success" - - def read_website(self, url: str) -> str: - """This function reads a website and returns the content. - - :param url: The url of the website to read. - :return: Relevant documents from the website. - """ - from phi.document.reader.website import WebsiteReader - - website = WebsiteReader() - - logger.debug(f"Reading website: {url}") - relevant_docs: List[Document] = website.read(url=url) - return json.dumps([doc.to_dict() for doc in relevant_docs]) diff --git a/phi/llm/function/wikipedia.py b/phi/llm/function/wikipedia.py deleted file mode 100644 index b2ca66957..000000000 --- a/phi/llm/function/wikipedia.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -from typing import List, Optional - -from phi.document import Document -from phi.knowledge.wikipedia import WikipediaKnowledgeBase -from phi.llm.function.registry import FunctionRegistry -from phi.utils.log import logger - - -class WikipediaRegistry(FunctionRegistry): - def __init__(self, knowledge_base: Optional[WikipediaKnowledgeBase] = None): - super().__init__(name="wikipedia_registry") - self.knowledge_base: Optional[WikipediaKnowledgeBase] = knowledge_base - - if self.knowledge_base is not None and isinstance(self.knowledge_base, WikipediaKnowledgeBase): - self.register(self.search_wikipedia_and_update_knowledge_base) - else: - self.register(self.search_wikipedia) - - def search_wikipedia_and_update_knowledge_base(self, topic: str) -> str: - """This function searches wikipedia for a topic, adds the results to the knowledge base and returns them. - - USE THIS FUNCTION TO GET INFORMATION WHICH DOES NOT EXIST. - - :param topic: The topic to search Wikipedia and add to knowledge base. - :return: Relevant documents from Wikipedia knowledge base. - """ - - if self.knowledge_base is None: - return "Knowledge base not provided" - - logger.debug(f"Adding to knowledge base: {topic}") - self.knowledge_base.topics.append(topic) - logger.debug("Loading knowledge base.") - self.knowledge_base.load(recreate=False) - logger.debug(f"Searching knowledge base: {topic}") - relevant_docs: List[Document] = self.knowledge_base.search(query=topic) - return json.dumps([doc.to_dict() for doc in relevant_docs]) - - def search_wikipedia(self, query: str) -> str: - """Searches Wikipedia for a query. - - :param query: The query to search for. - :return: Relevant documents from wikipedia. - """ - try: - import wikipedia # noqa: F401 - except ImportError: - raise ImportError( - "The `wikipedia` package is not installed. " "Please install it via `pip install wikipedia`." - ) - - logger.info(f"Searching wikipedia for: {query}") - return json.dumps(Document(name=query, content=wikipedia.summary(query)).to_dict()) diff --git a/phi/llm/openai/chat.py b/phi/llm/openai/chat.py index a774dfddd..f91e226d3 100644 --- a/phi/llm/openai/chat.py +++ b/phi/llm/openai/chat.py @@ -1,10 +1,12 @@ from typing import Optional, List, Iterator, Dict, Any, Union, Tuple from phi.llm.base import LLM -from phi.llm.schemas import Message, FunctionCall +from phi.llm.schemas import Message +from phi.tool.function import FunctionCall from phi.utils.env import get_from_env from phi.utils.log import logger from phi.utils.timer import Timer +from phi.utils.functions import get_function_call try: from openai import OpenAI @@ -72,19 +74,10 @@ def api_kwargs(self) -> Dict[str, Any]: if self.headers: kwargs["headers"] = self.headers if self.tools: - kwargs["tools"] = [t.to_dict() for t in self.tools] - if self.tool_choice is not None: - kwargs["tool_choice"] = self.tool_choice - if self.functions: - if kwargs.get("tools") is None: - kwargs["tools"] = [] - for f in self.functions.values(): - function_tool = { - "type": "function", - "function": f.to_dict(), - } - kwargs["tools"].append(function_tool) - if self.tool_choice is not None: + kwargs["tools"] = self.get_tools_for_api() + if self.tool_choice is None: + kwargs["tool_choice"] = "auto" + else: kwargs["tool_choice"] = self.tool_choice return kwargs @@ -163,9 +156,15 @@ def invoke_model_stream(self, messages: List[Message]) -> Iterator[ChatCompletio # logger.debug(f"Double chunk: {chunk}") chunks = "[" + chunk.replace("}{", "},{") + "]" for completion_chunk in json.loads(chunks): - yield ChatCompletionChunk.model_validate_json(completion_chunk) + try: + yield ChatCompletionChunk.model_validate_json(completion_chunk) + except Exception as e: + logger.warning(e) else: - yield ChatCompletionChunk.model_validate_json(chunk) + try: + yield ChatCompletionChunk.model_validate_json(chunk) + except Exception as e: + logger.warning(e) except Exception as e: logger.exception(e) logger.info("Please message us on https://discord.gg/4MtYHHrgA8 for help.") @@ -183,7 +182,9 @@ def run_function(self, function_call: Dict[str, Any]) -> Tuple[Message, Optional _function_arguments_str = function_call.get("arguments") if _function_name is not None: # Get function call - _function_call = self.get_function_call(name=_function_name, arguments=_function_arguments_str) + _function_call = get_function_call( + name=_function_name, arguments=_function_arguments_str, functions=self.functions + ) if _function_call is None: return Message(role="function", content="Could not find function to call."), None @@ -200,7 +201,7 @@ def run_function(self, function_call: Dict[str, Any]) -> Tuple[Message, Optional self.function_call_stack.append(_function_call) _function_call_timer = Timer() _function_call_timer.start() - _function_call.run() + _function_call.execute() _function_call_timer.stop() _function_call_message = Message( role="function", @@ -227,8 +228,10 @@ def run_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Message _tool_call_function_arguments_str = _tool_call_function.get("arguments") if _tool_call_function_name is not None: # Get tool call - function_call = self.get_function_call( - name=_tool_call_function_name, arguments=_tool_call_function_arguments_str + function_call = get_function_call( + name=_tool_call_function_name, + arguments=_tool_call_function_arguments_str, + functions=self.functions, ) if function_call is None: tool_call_results.append( @@ -256,7 +259,7 @@ def run_tool_calls(self, tool_calls: List[Dict[str, Any]]) -> List[Tuple[Message self.function_call_stack.append(function_call) tool_call_timer = Timer() tool_call_timer.start() - function_call.run() + function_call.execute() tool_call_timer.stop() tool_call_message = Message( role="tool", @@ -338,10 +341,6 @@ def parsed_response(self, messages: List[Message]) -> str: messages.append(assistant_message) assistant_message.log() - # -*- Return content if present, otherwise run function call - if assistant_message.content is not None: - return assistant_message.get_content_string() - # -*- Parse and run function call need_to_run_functions = assistant_message.function_call is not None or assistant_message.tool_calls is not None if need_to_run_functions and self.run_function_calls: @@ -364,7 +363,11 @@ def parsed_response(self, messages: List[Message]) -> str: # -*- Get new response using result of tool call final_response += self.parsed_response(messages=messages) return final_response + logger.debug("---------- OpenAI Response End ----------") + # -*- Return content if no function calls are present + if assistant_message.content is not None: + return assistant_message.get_content_string() return "Something went wrong, please try again." def response_message(self, messages: List[Message]) -> Dict: diff --git a/phi/llm/schemas.py b/phi/llm/schemas.py index 2f1d76e6b..014d7920c 100644 --- a/phi/llm/schemas.py +++ b/phi/llm/schemas.py @@ -1,5 +1,5 @@ -from typing import Optional, Any, Dict, Callable, List, get_type_hints, Union -from pydantic import BaseModel, validate_call +from typing import Optional, Any, Dict, List, Union +from pydantic import BaseModel from phi.utils.log import logger @@ -74,90 +74,3 @@ class References(BaseModel): references: str # Performance in seconds. time: Optional[float] = None - - -class Function(BaseModel): - """Model for LLM functions""" - - # 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] = {"type": "object", "properties": {}} - entrypoint: Optional[Callable] = None - - def to_dict(self) -> Dict[str, Any]: - return self.model_dump(exclude_none=True, exclude={"entrypoint"}) - - @classmethod - def from_callable(cls, c: Callable) -> "Function": - from inspect import getdoc - from phi.utils.json_schema import get_json_schema - - parameters = {"type": "object", "properties": {}} - try: - type_hints = get_type_hints(c) - parameters = get_json_schema(type_hints) - # logger.debug(f"Type hints for {c.__name__}: {type_hints}") - except Exception as e: - logger.warning(f"Could not parse args for {c.__name__}: {e}") - - return cls( - name=c.__name__, - description=getdoc(c), - parameters=parameters, - entrypoint=validate_call(c), - ) - - -class FunctionCall(BaseModel): - """Model for LLM 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 - - def get_call_str(self) -> str: - """Returns a string representation of the function call.""" - if self.arguments is None: - return f"{self.function.name}()" - return f"{self.function.name}({', '.join([f'{k}={v}' for k, v in self.arguments.items()])})" - - def run(self) -> bool: - """Runs the function call. - - @return: True if the function call was successful, False otherwise. - """ - if self.function.entrypoint is None: - return False - - logger.debug(f"Running: {self.get_call_str()}") - - # Call the function with no arguments if none are provided. - if self.arguments is None: - try: - self.result = self.function.entrypoint() - return True - except Exception as e: - logger.warning(f"Could not run function {self.get_call_str()}") - logger.error(e) - return False - - # Validate the arguments if provided. - # try: - # from jsonschema import validate - # except ImportError: - # raise ImportError("`jsonschema` is required for LLM functions, install using `pip install jsonschema`") - try: - self.result = self.function.entrypoint(**self.arguments) - return True - except Exception as e: - logger.warning(f"Could not run function {self.get_call_str()}") - logger.error(e) - return False diff --git a/phi/llm/task/llm_task.py b/phi/llm/task/llm_task.py index ad1998b55..e0e895474 100644 --- a/phi/llm/task/llm_task.py +++ b/phi/llm/task/llm_task.py @@ -6,10 +6,10 @@ from phi.document import Document from phi.knowledge.base import KnowledgeBase from phi.llm.base import LLM -from phi.llm.schemas import Message, References from phi.llm.openai import OpenAIChat -from phi.llm.agent.base import BaseAgent -from phi.llm.function.registry import FunctionRegistry +from phi.llm.schemas import Message, References +from phi.tool.tool import Tool +from phi.tool.registry import ToolRegistry from phi.llm.task.memory.base import TaskMemory from phi.utils.format_str import remove_indent from phi.utils.log import logger @@ -42,28 +42,32 @@ class LLMTask(BaseModel): add_references_to_prompt: bool = False # -*- Enable Function Calls - # Makes the task autonomous. + # Makes the task Autonomous by letting the LLM call functions to achieve tasks. function_calls: bool = False - # Add a list of default functions to the LLM + # Add default functions to the LLM when function_calls is True. default_functions: bool = True # Show function calls in LLM messages. show_function_calls: bool = False - # A list of functions to add to the LLM. - functions: Optional[List[Callable]] = None - # A list of function registries to add to the LLM. - function_registries: Optional[List[FunctionRegistry]] = None - # -*- Agents - # Add a list of agents to the LLM - # function_calls must be True for agents to be added to the LLM - agents: Optional[List[BaseAgent]] = None + # -*- Task Tools + # A list of tools provided to the LLM. + # Currently, only functions are supported as a tool. + # Use this to provide a list of functions the model may generate JSON inputs for. + tools: Optional[List[Union[Tool, Dict, Callable, ToolRegistry]]] = None + # Controls which (if any) function is called by the model. + # "none" means the model will not call a function and instead generates a message. + # "auto" means the model can pick between generating a message or calling a function. + # Specifying a particular function via {"type: "function", "function": {"name": "my_function"}} + # forces the model to call that function. + # "none" is the default when no functions are present. "auto" is the default if functions are present. + tool_choice: Optional[Union[str, Dict[str, Any]]] = None # # -*- Prompt Settings # - # -*- System prompt: provide the system prompt as a string or using a function + # -*- System prompt: provide the system prompt as a string system_prompt: Optional[str] = None - # Function to build the system prompt. + # -*- System prompt function: provide the system prompt as a function # This function is provided the task as an argument # and should return the system_prompt as a string. # Signature: @@ -73,10 +77,10 @@ class LLMTask(BaseModel): # If True, the task provides a default system prompt use_default_system_prompt: bool = True - # -*- User prompt: provide the user prompt as a string or using a function + # -*- User prompt: provide the user prompt as a string # Note: this will ignore the message provided to the run function user_prompt: Optional[Union[List[Dict], str]] = None - # Function to build the user prompt. + # -*- User prompt function: provide the user prompt as a function. # This function is provided the task and the input message as arguments # and should return the user_prompt as a Union[List[Dict], str]. # If add_references_to_prompt is True, then references are also provided as an argument. @@ -106,52 +110,25 @@ def set_task_id(self) -> None: if self.id is None: self.id = str(uuid4()) - def add_functions_to_llm(self) -> None: - if self.llm is None: - return - - if self.function_calls: - if self.default_functions: - default_func_list: List[Callable] = [ - self.get_last_n_chats, - self.search_knowledge_base, - ] - for func in default_func_list: - self.llm.add_function(func) - - # Add functions from self.functions - if self.functions is not None: - for func in self.functions: - self.llm.add_function(func) - - # Add functions from registries - if self.function_registries is not None: - for registry in self.function_registries: - self.llm.add_function_registry(registry) - - # Set function call to auto if it is not set - if self.llm.function_call is None: - self.llm.function_call = "auto" - - # Set show_function_calls if it is not set on the llm - if self.llm.show_function_calls is None: - self.llm.show_function_calls = self.show_function_calls - - def add_agents_to_llm(self) -> None: + def add_tools_to_llm(self) -> None: if self.llm is None: return - if self.agents is not None and len(self.agents) > 0: - for agent in self.agents: - self.llm.add_agent(agent) + if self.function_calls and self.default_functions: + default_func_list: List[Callable] = [ + self.get_last_n_chats, + self.search_knowledge_base, + ] + for func in default_func_list: + self.llm.add_tool(func) - # Set function call to auto if it is not set - if self.llm.function_call is None: - self.llm.function_call = "auto" + # Set show_function_calls if it is not set on the llm + if self.llm.show_function_calls is None and self.show_function_calls is not None: + self.llm.show_function_calls = self.show_function_calls - # Set show_function_calls if it is not set on the llm - if self.llm.show_function_calls is None: - self.llm.show_function_calls = self.show_function_calls + # Set tool_choice to auto if it is not set on the llm + if self.llm.tool_choice is None and self.tool_choice is not None: + self.llm.tool_choice = self.tool_choice def get_system_prompt(self) -> Optional[str]: """Return the system prompt for the task""" @@ -287,8 +264,7 @@ def _run(self, message: Optional[Union[List[Dict], str]] = None, stream: bool = # -*- Prepare the task self.set_task_id() - self.add_functions_to_llm() - self.add_agents_to_llm() + self.add_tools_to_llm() # -*- Build the system prompt system_prompt = self.get_system_prompt() diff --git a/phi/tool/__init__.py b/phi/tool/__init__.py new file mode 100644 index 000000000..044f3f5b1 --- /dev/null +++ b/phi/tool/__init__.py @@ -0,0 +1 @@ +from phi.tool.tool import Tool diff --git a/phi/assistant/tool/arxiv.py b/phi/tool/arxiv.py similarity index 96% rename from phi/assistant/tool/arxiv.py rename to phi/tool/arxiv.py index 289b35fc8..508aadc0e 100644 --- a/phi/assistant/tool/arxiv.py +++ b/phi/tool/arxiv.py @@ -3,11 +3,11 @@ from phi.document import Document from phi.knowledge.arxiv import ArxivKnowledgeBase -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class ArxivTools(ToolRegistry): +class ArxivTool(ToolRegistry): def __init__(self, knowledge_base: Optional[ArxivKnowledgeBase] = None): super().__init__(name="arxiv_tools") self.knowledge_base: Optional[ArxivKnowledgeBase] = knowledge_base diff --git a/phi/assistant/tool/duckdb.py b/phi/tool/duckdb.py similarity index 99% rename from phi/assistant/tool/duckdb.py rename to phi/tool/duckdb.py index 51eb0ee29..ac374e7dd 100644 --- a/phi/assistant/tool/duckdb.py +++ b/phi/tool/duckdb.py @@ -1,6 +1,6 @@ from typing import Optional, Tuple -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger try: @@ -9,7 +9,7 @@ raise ImportError("`duckdb` not installed. Please install it using `pip install duckdb`.") -class DuckDbTools(ToolRegistry): +class DuckDbTool(ToolRegistry): def __init__( self, db_path: str = ":memory:", diff --git a/phi/assistant/tool/email.py b/phi/tool/email.py similarity index 96% rename from phi/assistant/tool/email.py rename to phi/tool/email.py index 284a8e220..9313df0bf 100644 --- a/phi/assistant/tool/email.py +++ b/phi/tool/email.py @@ -1,10 +1,10 @@ from typing import Optional -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class EmailTools(ToolRegistry): +class EmailTool(ToolRegistry): def __init__( self, receiver_email: Optional[str] = None, diff --git a/phi/assistant/function.py b/phi/tool/function.py similarity index 90% rename from phi/assistant/function.py rename to phi/tool/function.py index ffb90a4e8..dcb0fcbbd 100644 --- a/phi/assistant/function.py +++ b/phi/tool/function.py @@ -4,19 +4,8 @@ from phi.utils.log import logger -class Tool(BaseModel): - """Model for Assistant Tools""" - - # The type of tool - type: str - function: Optional[Dict[str, Any]] = None - - def to_dict(self) -> Dict[str, Any]: - return self.model_dump(exclude_none=True) - - class Function(BaseModel): - """Model for Assistant functions""" + """Model for Functions""" # 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. @@ -53,7 +42,7 @@ def from_callable(cls, c: Callable) -> "Function": class FunctionCall(BaseModel): - """Model for Assistant function calls""" + """Model for Function Calls""" # The function to be called. function: Function diff --git a/phi/assistant/tool/phi.py b/phi/tool/phi.py similarity index 98% rename from phi/assistant/tool/phi.py rename to phi/tool/phi.py index c691ffe7a..b826390b8 100644 --- a/phi/assistant/tool/phi.py +++ b/phi/tool/phi.py @@ -1,11 +1,11 @@ import uuid from typing import Optional -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class PhiTools(ToolRegistry): +class PhiTool(ToolRegistry): def __init__(self): super().__init__(name="phi_tools") self.register(self.create_new_app) diff --git a/phi/assistant/tool/registry.py b/phi/tool/registry.py similarity index 89% rename from phi/assistant/tool/registry.py rename to phi/tool/registry.py index 9c562fc01..162dc9514 100644 --- a/phi/assistant/tool/registry.py +++ b/phi/tool/registry.py @@ -1,12 +1,12 @@ from collections import OrderedDict from typing import Callable, Dict -from phi.assistant.function import Function +from phi.tool.function import Function from phi.utils.log import logger class ToolRegistry: - def __init__(self, name: str = "default_registry"): + def __init__(self, name: str = "default_tools"): self.name: str = name self.functions: Dict[str, Function] = OrderedDict() diff --git a/phi/assistant/tool/shell.py b/phi/tool/shell.py similarity index 92% rename from phi/assistant/tool/shell.py rename to phi/tool/shell.py index 8f2047319..bccf6868f 100644 --- a/phi/assistant/tool/shell.py +++ b/phi/tool/shell.py @@ -1,10 +1,10 @@ from typing import List -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class ShellTools(ToolRegistry): +class ShellTool(ToolRegistry): def __init__(self): super().__init__(name="shell_tools") self.register(self.run_shell_command) diff --git a/phi/assistant/tool/tool.py b/phi/tool/tool.py similarity index 77% rename from phi/assistant/tool/tool.py rename to phi/tool/tool.py index 7c353ad76..48f8db79c 100644 --- a/phi/assistant/tool/tool.py +++ b/phi/tool/tool.py @@ -3,10 +3,11 @@ class Tool(BaseModel): - """Model for Assistant Tools""" + """Model for Tools""" # The type of tool type: str + # The function to be called if type = "function" function: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: diff --git a/phi/assistant/tool/website.py b/phi/tool/website.py similarity index 95% rename from phi/assistant/tool/website.py rename to phi/tool/website.py index cbea35a5d..b9d6a79de 100644 --- a/phi/assistant/tool/website.py +++ b/phi/tool/website.py @@ -3,11 +3,11 @@ from phi.document import Document from phi.knowledge.website import WebsiteKnowledgeBase -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class WebsiteTools(ToolRegistry): +class WebsiteTool(ToolRegistry): def __init__(self, knowledge_base: Optional[WebsiteKnowledgeBase] = None): super().__init__(name="website_tools") self.knowledge_base: Optional[WebsiteKnowledgeBase] = knowledge_base diff --git a/phi/assistant/tool/wikipedia.py b/phi/tool/wikipedia.py similarity index 96% rename from phi/assistant/tool/wikipedia.py rename to phi/tool/wikipedia.py index cedd12d30..715dbef6d 100644 --- a/phi/assistant/tool/wikipedia.py +++ b/phi/tool/wikipedia.py @@ -3,11 +3,11 @@ from phi.document import Document from phi.knowledge.wikipedia import WikipediaKnowledgeBase -from phi.assistant.tool.registry import ToolRegistry +from phi.tool.registry import ToolRegistry from phi.utils.log import logger -class WikipediaAgent(ToolRegistry): +class WikipediaTool(ToolRegistry): def __init__(self, knowledge_base: Optional[WikipediaKnowledgeBase] = None): super().__init__(name="wikipedia_tools") self.knowledge_base: Optional[WikipediaKnowledgeBase] = knowledge_base diff --git a/phi/utils/functions.py b/phi/utils/functions.py index 0b783c217..5be259f27 100644 --- a/phi/utils/functions.py +++ b/phi/utils/functions.py @@ -1,7 +1,7 @@ import json from typing import Optional, Dict, Any -from phi.llm.schemas import Function, FunctionCall +from phi.tool.function import Function, FunctionCall from phi.utils.log import logger