Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add integration with Composio tools #1820

Merged
merged 5 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions examples/composio_tool_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json
import uuid

from letta import create_client
from letta.schemas.memory import ChatMemory
from letta.schemas.tool import Tool

"""
Setup here.
"""
# Create a `LocalClient` (you can also use a `RESTClient`, see the letta_rest_client.py example)
client = create_client()

# Generate uuid for agent name for this example
namespace = uuid.NAMESPACE_DNS
agent_uuid = str(uuid.uuid5(namespace, "letta-composio-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)}")


"""
This example show how you can add Composio tools .

First, make sure you have Composio and some of the extras downloaded.
```
poetry install --extras "external-tools"
```
then setup letta with `letta configure`.

Aditionally, this example stars a Github repo on your behalf. You will need to configure Composio in your environment.
```
composio login
composio add github
```

Last updated Oct 2, 2024. Please check `composio` documentation for any composio related issues.
"""


def main():
from composio_langchain import Action

# Add the composio tool
tool = Tool.get_composio_tool(action=Action.GITHUB_STAR_A_REPOSITORY_FOR_THE_AUTHENTICATED_USER)

# create tool
client.add_tool(tool)

persona = f"""
My name is Letta.

I am a personal assistant that helps star repos on Github. It is my job to correctly input the owner and repo to the {tool.name} tool based on the user's request.

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="Star a repo composio with owner composiohq on GitHub")
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()
48 changes: 47 additions & 1 deletion letta/functions/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
from pydantic import BaseModel


def generate_composio_tool_wrapper(action: "ActionType") -> tuple[str, str]:
# Instantiate the object
tool_instantiation_str = f"composio_toolset.get_tools(actions=[Action.{action.name}])[0]"

# Generate func name
func_name = f"run_{action.name}"

wrapper_function_str = f"""
def {func_name}(**kwargs):
if 'self' in kwargs:
del kwargs['self']
from composio import Action, App, Tag
from composio_langchain import ComposioToolSet

composio_toolset = ComposioToolSet()
tool = {tool_instantiation_str}
tool.func(**kwargs)
"""

# Compile safety check
assert_code_gen_compilable(wrapper_function_str)

return func_name, wrapper_function_str


def generate_langchain_tool_wrapper(
tool: "LangChainBaseTool", additional_imports_module_attr_map: dict[str, str] = None
) -> tuple[str, str]:
Expand All @@ -28,6 +53,10 @@ def {func_name}(**kwargs):
{tool_instantiation}
{run_call}
"""

# Compile safety check
assert_code_gen_compilable(wrapper_function_str)

return func_name, wrapper_function_str


Expand All @@ -48,14 +77,26 @@ def generate_crewai_tool_wrapper(tool: "CrewAIBaseTool", additional_imports_modu
def {func_name}(**kwargs):
if 'self' in kwargs:
del kwargs['self']
import importlib
{import_statement}
{extra_module_imports}
{tool_instantiation}
{run_call}
"""

# Compile safety check
assert_code_gen_compilable(wrapper_function_str)

return func_name, wrapper_function_str


def assert_code_gen_compilable(code_str):
try:
compile(code_str, "<string>", "exec")
except SyntaxError as e:
print(f"Syntax error in code: {e}")


def assert_all_classes_are_imported(
tool: Union["LangChainBaseTool", "CrewAIBaseTool"], additional_imports_module_attr_map: dict[str, str]
) -> None:
Expand Down Expand Up @@ -129,7 +170,7 @@ def generate_imported_tool_instantiation_call_str(obj: Any) -> Optional[str]:
# e.g. {arg}={value}
# The reason why this is recursive, is because the value can be another BaseModel that we need to stringify
model_name = obj.__class__.__name__
fields = dict(obj)
fields = obj.dict()
# Generate code for each field, skipping empty or None values
field_assignments = []
for arg, value in fields.items():
Expand Down Expand Up @@ -168,6 +209,11 @@ def generate_imported_tool_instantiation_call_str(obj: Any) -> Optional[str]:
print(
f"[WARNING] Skipping parsing unknown class {obj.__class__.__name__} (does not inherit from the Pydantic BaseModel and is not a basic Python type)"
)
if obj.__class__.__name__ == "function":
import inspect

print(inspect.getsource(obj))

return None


Expand Down
2 changes: 2 additions & 0 deletions letta/llm_api/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ def openai_chat_completions_request(
try:
response = requests.post(url, headers=headers, json=data)
printd(f"response = {response}, response.text = {response.text}")
# print(json.dumps(data, indent=4))
# raise requests.exceptions.HTTPError
response.raise_for_status() # Raises HTTPError for 4XX/5XX status

response = response.json() # convert to dict from string
Expand Down
49 changes: 49 additions & 0 deletions letta/schemas/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pydantic import Field

from letta.functions.helpers import (
generate_composio_tool_wrapper,
generate_crewai_tool_wrapper,
generate_langchain_tool_wrapper,
)
Expand Down Expand Up @@ -57,6 +58,54 @@ def to_dict(self):
)
)

@classmethod
def get_composio_tool(
cls,
action: "ActionType",
) -> "Tool":
"""
Class method to create an instance of Letta-compatible Composio Tool.
Check https://docs.composio.dev/introduction/intro/overview to look at options for get_composio_tool

This function will error if we find more than one tool, or 0 tools.

Args:
action ActionType: A action name to filter tools by.
Returns:
Tool: A Letta Tool initialized with attributes derived from the Composio tool.
"""
from composio_langchain import ComposioToolSet

composio_toolset = ComposioToolSet()
composio_tools = composio_toolset.get_tools(actions=[action])

assert len(composio_tools) > 0, "User supplied parameters do not match any Composio tools"
assert len(composio_tools) == 1, f"User supplied parameters match too many Composio tools; {len(composio_tools)} > 1"

composio_tool = composio_tools[0]

description = composio_tool.description
source_type = "python"
tags = ["composio"]
wrapper_func_name, wrapper_function_str = generate_composio_tool_wrapper(action)
json_schema = generate_schema_from_args_schema(composio_tool.args_schema, name=wrapper_func_name, description=description)

# append heartbeat (necessary for triggering another reasoning step after this tool call)
json_schema["parameters"]["properties"]["request_heartbeat"] = {
"type": "boolean",
"description": "Request an immediate heartbeat after function execution. Set to 'true' if you want to send a follow-up message or run a follow-up function.",
}
json_schema["parameters"]["required"].append("request_heartbeat")

return cls(
name=wrapper_func_name,
description=description,
source_type=source_type,
tags=tags,
source_code=wrapper_function_str,
json_schema=json_schema,
)

@classmethod
def from_langchain(cls, langchain_tool: "LangChainBaseTool", additional_imports_module_attr_map: dict[str, str] = None) -> "Tool":
"""
Expand Down
Loading
Loading