diff --git a/examples/crewai_tool_usage.py b/examples/crewai_tool_usage.py new file mode 100644 index 00000000..c8d6f1cf --- /dev/null +++ b/examples/crewai_tool_usage.py @@ -0,0 +1,73 @@ +import json +import uuid + +from letta import create_client +from letta.schemas.memory import ChatMemory +from letta.schemas.tool import Tool + +""" +This example show how you can add CrewAI tools . + +First, make sure you have CrewAI and some of the extras downloaded. +``` +poetry install --extras "external-tools" +``` +then setup letta with `letta configure`. +""" + + +def main(): + from crewai_tools import ScrapeWebsiteTool + + crewai_tool = ScrapeWebsiteTool(website_url="https://www.example.com") + + example_website_scrape_tool = Tool.from_crewai(crewai_tool) + tool_name = example_website_scrape_tool.name + + # Create a `LocalClient` (you can also use a `RESTClient`, see the letta_rest_client.py example) + client = create_client() + + # create tool + client.add_tool(example_website_scrape_tool) + + # Confirm that the tool is in + tools = client.list_tools() + assert example_website_scrape_tool.name in [t.name for t in tools] + + # Generate uuid for agent name for this example + namespace = uuid.NAMESPACE_DNS + agent_uuid = str(uuid.uuid5(namespace, "letta-crewai-tooling-example")) + + # Clear all agents + for agent_state in client.list_agents(): + if agent_state.name == agent_uuid: + client.delete_agent(agent_id=agent_state.id) + print(f"Deleted agent: {agent_state.name} with ID {str(agent_state.id)}") + + # google search persona + persona = f""" + + My name is Letta. + + I am a personal assistant who answers a user's questions about a website `example.com`. When a user asks me a question about `example.com`, I will use a tool called {tool_name} which will search `example.com` and answer the relevant question. + + Don’t forget - inner monologue / inner thoughts should always be different than the contents of send_message! send_message is how you communicate with the user, whereas inner thoughts are your own personal inner thoughts. + """ + + # Create an agent + agent_state = client.create_agent(name=agent_uuid, memory=ChatMemory(human="My name is Matt.", persona=persona), tools=[tool_name]) + print(f"Created agent: {agent_state.name} with ID {str(agent_state.id)}") + + # Send a message to the agent + send_message_response = client.user_message(agent_id=agent_state.id, message="What's on the example.com website?") + for message in send_message_response.messages: + response_json = json.dumps(message.model_dump(), indent=4) + print(f"{response_json}\n") + + # Delete agent + client.delete_agent(agent_id=agent_state.id) + print(f"Deleted agent: {agent_state.name} with ID {str(agent_state.id)}") + + +if __name__ == "__main__": + main() diff --git a/examples/langchain_tools.py b/examples/langchain_tool_usage.py similarity index 98% rename from examples/langchain_tools.py rename to examples/langchain_tool_usage.py index 6080ddda..d77240b9 100644 --- a/examples/langchain_tools.py +++ b/examples/langchain_tool_usage.py @@ -11,8 +11,7 @@ This example show how you can add LangChain tools . First, make sure you have LangChain and some of the extras downloaded. For this specific example, you will need `wikipedia` installed. ``` -poetry install --extras "tests" -poetry install langchain +poetry install --extras "external-tools" ``` then setup letta with `letta configure`. """ diff --git a/letta/agent.py b/letta/agent.py index 3217a52d..831e0f4a 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -551,18 +551,19 @@ class Agent(BaseAgent): ) # extend conversation with assistant's reply printd(f"Function call message: {messages[-1]}") - # The content if then internal monologue, not chat - self.interface.internal_monologue(response_message.content, msg_obj=messages[-1]) + if response_message.content: + # The content if then internal monologue, not chat + self.interface.internal_monologue(response_message.content, msg_obj=messages[-1]) # Step 3: call the function # Note: the JSON response may not always be valid; be sure to handle errors - - # Failure case 1: function name is wrong function_call = ( response_message.function_call if response_message.function_call is not None else response_message.tool_calls[0].function ) function_name = function_call.name printd(f"Request to call function {function_name} with tool_call_id: {tool_call_id}") + + # Failure case 1: function name is wrong try: function_to_call = self.functions_python[function_name] except KeyError: diff --git a/letta/functions/helpers.py b/letta/functions/helpers.py index 5b7cd33a..e9f41e01 100644 --- a/letta/functions/helpers.py +++ b/letta/functions/helpers.py @@ -3,21 +3,15 @@ from typing import Any, Optional, Union from pydantic import BaseModel -def generate_langchain_tool_wrapper(tool: "LangChainBaseTool", additional_imports_module_attr_map: dict = None) -> tuple[str, str]: +def generate_langchain_tool_wrapper( + tool: "LangChainBaseTool", additional_imports_module_attr_map: dict[str, str] = None +) -> tuple[str, str]: tool_name = tool.__class__.__name__ import_statement = f"from langchain_community.tools import {tool_name}" extra_module_imports = generate_import_code(additional_imports_module_attr_map) # Safety check that user has passed in all required imports: - current_class_imports = {tool_name} - if additional_imports_module_attr_map: - current_class_imports.update(set(additional_imports_module_attr_map.values())) - required_class_imports = set(find_required_class_names_for_import(tool)) - - if not current_class_imports.issuperset(required_class_imports): - err_msg = f"[ERROR] You are missing module_attr pairs in `additional_imports_module_attr_map`. Currently, you have imports for {current_class_imports}, but the required classes for import are {required_class_imports}" - print(err_msg) - raise RuntimeError(err_msg) + assert_all_classes_are_imported(tool, additional_imports_module_attr_map) tool_instantiation = f"tool = {generate_imported_tool_instantiation_call_str(tool)}" run_call = f"return tool._run(**kwargs)" @@ -37,9 +31,14 @@ def {func_name}(**kwargs): return func_name, wrapper_function_str -def generate_crewai_tool_wrapper(tool: "CrewAIBaseTool") -> tuple[str, str]: +def generate_crewai_tool_wrapper(tool: "CrewAIBaseTool", additional_imports_module_attr_map: dict[str, str] = None) -> tuple[str, str]: tool_name = tool.__class__.__name__ import_statement = f"from crewai_tools import {tool_name}" + extra_module_imports = generate_import_code(additional_imports_module_attr_map) + + # Safety check that user has passed in all required imports: + assert_all_classes_are_imported(tool, additional_imports_module_attr_map) + tool_instantiation = f"tool = {generate_imported_tool_instantiation_call_str(tool)}" run_call = f"return tool._run(**kwargs)" func_name = f"run_{tool_name.lower()}" @@ -50,12 +49,29 @@ def {func_name}(**kwargs): if 'self' in kwargs: del kwargs['self'] {import_statement} + {extra_module_imports} {tool_instantiation} {run_call} """ return func_name, wrapper_function_str +def assert_all_classes_are_imported( + tool: Union["LangChainBaseTool", "CrewAIBaseTool"], additional_imports_module_attr_map: dict[str, str] +) -> None: + # Safety check that user has passed in all required imports: + tool_name = tool.__class__.__name__ + current_class_imports = {tool_name} + if additional_imports_module_attr_map: + current_class_imports.update(set(additional_imports_module_attr_map.values())) + required_class_imports = set(find_required_class_names_for_import(tool)) + + if not current_class_imports.issuperset(required_class_imports): + err_msg = f"[ERROR] You are missing module_attr pairs in `additional_imports_module_attr_map`. Currently, you have imports for {current_class_imports}, but the required classes for import are {required_class_imports}" + print(err_msg) + raise RuntimeError(err_msg) + + def find_required_class_names_for_import(obj: Union["LangChainBaseTool", "CrewAIBaseTool", BaseModel]) -> list[str]: """ Finds all the class names for required imports when instantiating the `obj`. diff --git a/letta/schemas/tool.py b/letta/schemas/tool.py index 2a4f6acb..e445955c 100644 --- a/letta/schemas/tool.py +++ b/letta/schemas/tool.py @@ -93,7 +93,7 @@ class Tool(BaseTool): ) @classmethod - def from_crewai(cls, crewai_tool: "CrewAIBaseTool") -> "Tool": + def from_crewai(cls, crewai_tool: "CrewAIBaseTool", additional_imports_module_attr_map: dict[str, str] = None) -> "Tool": """ Class method to create an instance of Tool from a crewAI BaseTool object. @@ -106,7 +106,7 @@ class Tool(BaseTool): description = crewai_tool.description source_type = "python" tags = ["crew-ai"] - wrapper_func_name, wrapper_function_str = generate_crewai_tool_wrapper(crewai_tool) + wrapper_func_name, wrapper_function_str = generate_crewai_tool_wrapper(crewai_tool, additional_imports_module_attr_map) json_schema = generate_schema_from_args_schema(crewai_tool.args_schema, name=wrapper_func_name, description=description) # append heartbeat (necessary for triggering another reasoning step after this tool call) diff --git a/tests/test_new_client.py b/tests/test_new_client.py index 4de8e016..ee970354 100644 --- a/tests/test_new_client.py +++ b/tests/test_new_client.py @@ -292,11 +292,45 @@ def test_tools_from_crewai(client): # Pull a simple HTML website and check that scraping it works # TODO: This is very hacky and can break at any time if the website changes. # Host our own websites to test website tool calling on. - simple_webpage_url = "https://www.york.ac.uk/teaching/cws/wws/webpage1.html" - expected_content = "There are lots of ways to create web pages using already coded programmes." + simple_webpage_url = "https://www.example.com" + expected_content = "This domain is for use in illustrative examples in documents." assert expected_content in func(website_url=simple_webpage_url) +def test_tools_from_crewai_with_params(client): + # create crewAI tool + + from crewai_tools import ScrapeWebsiteTool + + from letta.schemas.tool import Tool + + crewai_tool = ScrapeWebsiteTool(website_url="https://www.example.com") + + # Translate to memGPT Tool + tool = Tool.from_crewai(crewai_tool) + + # Add the tool + client.add_tool(tool) + + # list tools + tools = client.list_tools() + assert tool.name in [t.name for t in tools] + + # get tool + tool_id = client.get_tool_id(name=tool.name) + retrieved_tool = client.get_tool(tool_id) + source_code = retrieved_tool.source_code + + # Parse the function and attempt to use it + local_scope = {} + exec(source_code, {}, local_scope) + func = local_scope[tool.name] + + # Pull a simple HTML website and check that scraping it works + expected_content = "This domain is for use in illustrative examples in documents." + assert expected_content in func() + + def test_tools_from_langchain(client): # create langchain tool from langchain_community.tools import WikipediaQueryRun