diff --git a/examples/composio_tool_usage.py b/examples/composio_tool_usage.py index fc6c3c12..d32546d1 100644 --- a/examples/composio_tool_usage.py +++ b/examples/composio_tool_usage.py @@ -9,7 +9,6 @@ from letta.schemas.llm_config import LLMConfig from letta.schemas.memory import ChatMemory from letta.schemas.sandbox_config import SandboxType from letta.services.sandbox_config_manager import SandboxConfigManager -from letta.settings import tool_settings """ Setup here. @@ -31,7 +30,7 @@ for agent_state in client.list_agents(): # Add sandbox env -manager = SandboxConfigManager(tool_settings) +manager = SandboxConfigManager() # Ensure you have e2b key set sandbox_config = manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=client.user) manager.create_sandbox_env_var( diff --git a/letta/agent.py b/letta/agent.py index 5202bac2..d4a71b42 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -3,12 +3,13 @@ import time import traceback import warnings from abc import ABC, abstractmethod -from typing import List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union from openai.types.beta.function_tool import FunctionTool as OpenAITool from letta.constants import ( CLI_WARNING_PREFIX, + COMPOSIO_ENTITY_ENV_VAR_KEY, ERROR_MESSAGE_PREFIX, FIRST_MESSAGE_ATTEMPTS, FUNC_FAILED_HEARTBEAT_MESSAGE, @@ -20,7 +21,11 @@ from letta.constants import ( from letta.errors import ContextWindowExceededError from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_function_annotations_from_source from letta.functions.functions import get_function_from_module +from letta.functions.helpers import execute_composio_action, generate_composio_action_from_func_name from letta.helpers import ToolRulesSolver +from letta.helpers.composio_helpers import get_composio_api_key +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps, json_loads from letta.interface import AgentInterface from letta.llm_api.helpers import calculate_summarizer_cutoff, get_token_counts_for_messages, is_context_overflow_error from letta.llm_api.llm_api_tools import create @@ -51,6 +56,7 @@ from letta.services.passage_manager import PassageManager from letta.services.provider_manager import ProviderManager from letta.services.step_manager import StepManager from letta.services.tool_execution_sandbox import ToolExecutionSandbox +from letta.services.tool_manager import ToolManager from letta.settings import summarizer_settings from letta.streaming_interface import StreamingRefreshCLIInterface from letta.system import get_heartbeat, get_token_limit_warning, package_function_response, package_summarize_message, package_user_message @@ -58,9 +64,6 @@ from letta.utils import ( count_tokens, get_friendly_error_msg, get_tool_call_id, - get_utc_time, - json_dumps, - json_loads, log_telemetry, parse_json, printd, @@ -202,7 +205,7 @@ class Agent(BaseAgent): def execute_tool_and_persist_state( self, function_name: str, function_args: dict, target_letta_tool: Tool - ) -> tuple[str, Optional[SandboxRunResult]]: + ) -> tuple[Any, Optional[SandboxRunResult]]: """ Execute tool modifications and persist the state of the agent. Note: only some agent state modifications will be persisted, such as data in the AgentState ORM and block data @@ -228,6 +231,18 @@ class Agent(BaseAgent): function_args["agent_state"] = agent_state_copy # need to attach self to arg since it's dynamically linked function_response = callable_func(**function_args) self.update_memory_if_changed(agent_state_copy.memory) + elif target_letta_tool.tool_type == ToolType.EXTERNAL_COMPOSIO: + action_name = generate_composio_action_from_func_name(target_letta_tool.name) + # Get entity ID from the agent_state + entity_id = None + for env_var in self.agent_state.tool_exec_environment_variables: + if env_var.key == COMPOSIO_ENTITY_ENV_VAR_KEY: + entity_id = env_var.value + # Get composio_api_key + composio_api_key = get_composio_api_key(actor=self.user, logger=self.logger) + function_response = execute_composio_action( + action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id + ) else: # Parse the source code to extract function annotations annotations = get_function_annotations_from_source(target_letta_tool.source_code, function_name) @@ -460,7 +475,10 @@ class Agent(BaseAgent): target_letta_tool = None for t in self.agent_state.tools: if t.name == function_name: - target_letta_tool = t + # This force refreshes the target_letta_tool from the database + # We only do this on name match to confirm that the agent state contains a specific tool with the right name + target_letta_tool = ToolManager().get_tool_by_name(tool_name=function_name, actor=self.user) + break if not target_letta_tool: error_msg = f"No function named {function_name}" diff --git a/letta/cli/cli_config.py b/letta/cli/cli_config.py index 12c03948..a17bf476 100644 --- a/letta/cli/cli_config.py +++ b/letta/cli/cli_config.py @@ -8,7 +8,7 @@ import typer from prettytable.colortable import ColorTable, Themes from tqdm import tqdm -from letta import utils +import letta.helpers.datetime_helpers app = typer.Typer() @@ -51,7 +51,7 @@ def list(arg: Annotated[ListChoice, typer.Argument]): agent.memory.get_block("persona").value[:100] + "...", agent.memory.get_block("human").value[:100] + "...", ",".join(source_names), - utils.format_datetime(agent.created_at), + letta.helpers.datetime_helpers.format_datetime(agent.created_at), ] ) print(table) @@ -84,7 +84,7 @@ def list(arg: Annotated[ListChoice, typer.Argument]): source.description, source.embedding_config.embedding_model, source.embedding_config.embedding_dim, - utils.format_datetime(source.created_at), + letta.helpers.datetime_helpers.format_datetime(source.created_at), ] ) diff --git a/letta/functions/function_sets/base.py b/letta/functions/function_sets/base.py index d3ca097b..590f79c8 100644 --- a/letta/functions/function_sets/base.py +++ b/letta/functions/function_sets/base.py @@ -33,7 +33,7 @@ def conversation_search(self: "Agent", query: str, page: Optional[int] = 0) -> O import math from letta.constants import RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE - from letta.utils import json_dumps + from letta.helpers.json_helpers import json_dumps if page is None or (isinstance(page, str) and page.lower().strip() == "none"): page = 0 diff --git a/letta/functions/function_sets/extras.py b/letta/functions/function_sets/extras.py index 65652b91..8169b593 100644 --- a/letta/functions/function_sets/extras.py +++ b/letta/functions/function_sets/extras.py @@ -5,9 +5,9 @@ from typing import Optional import requests from letta.constants import MESSAGE_CHATGPT_FUNCTION_MODEL, MESSAGE_CHATGPT_FUNCTION_SYSTEM_MESSAGE +from letta.helpers.json_helpers import json_dumps, json_loads from letta.llm_api.llm_api_tools import create from letta.schemas.message import Message, TextContent -from letta.utils import json_dumps, json_loads def message_chatgpt(self, message: str): diff --git a/letta/functions/helpers.py b/letta/functions/helpers.py index ef42b4c9..ef2de88b 100644 --- a/letta/functions/helpers.py +++ b/letta/functions/helpers.py @@ -72,6 +72,22 @@ def {func_name}(**kwargs): return func_name, wrapper_function_str +def execute_composio_action( + action_name: str, args: dict, api_key: Optional[str] = None, entity_id: Optional[str] = None +) -> tuple[str, str]: + import os + + from composio_langchain import ComposioToolSet + + entity_id = entity_id or os.getenv(COMPOSIO_ENTITY_ENV_VAR_KEY, DEFAULT_ENTITY_ID) + composio_toolset = ComposioToolSet(api_key=api_key, entity_id=entity_id) + response = composio_toolset.execute_action(action=action_name, params=args) + + if response["error"]: + raise RuntimeError(response["error"]) + return response["data"] + + def generate_langchain_tool_wrapper( tool: "LangChainBaseTool", additional_imports_module_attr_map: dict[str, str] = None ) -> tuple[str, str]: diff --git a/letta/helpers/composio_helpers.py b/letta/helpers/composio_helpers.py new file mode 100644 index 00000000..8a8c3249 --- /dev/null +++ b/letta/helpers/composio_helpers.py @@ -0,0 +1,21 @@ +from logging import Logger +from typing import Optional + +from letta.schemas.user import User +from letta.services.sandbox_config_manager import SandboxConfigManager +from letta.settings import tool_settings + + +def get_composio_api_key(actor: User, logger: Logger) -> Optional[str]: + api_keys = SandboxConfigManager().list_sandbox_env_vars_by_key(key="COMPOSIO_API_KEY", actor=actor) + if not api_keys: + logger.warning(f"No API keys found for Composio. Defaulting to the environment variable...") + if tool_settings.composio_api_key: + return tool_settings.composio_api_key + else: + return None + else: + # TODO: Add more protections around this + # Ideally, not tied to a specific sandbox, but for now we just get the first one + # Theoretically possible for someone to have different composio api keys per sandbox + return api_keys[0].value diff --git a/letta/helpers/datetime_helpers.py b/letta/helpers/datetime_helpers.py new file mode 100644 index 00000000..e99074a6 --- /dev/null +++ b/letta/helpers/datetime_helpers.py @@ -0,0 +1,90 @@ +import re +from datetime import datetime, timedelta, timezone + +import pytz + + +def parse_formatted_time(formatted_time): + # parse times returned by letta.utils.get_formatted_time() + return datetime.strptime(formatted_time, "%Y-%m-%d %I:%M:%S %p %Z%z") + + +def datetime_to_timestamp(dt): + # convert datetime object to integer timestamp + return int(dt.timestamp()) + + +def timestamp_to_datetime(ts): + # convert integer timestamp to datetime object + return datetime.fromtimestamp(ts) + + +def get_local_time_military(): + # Get the current time in UTC + current_time_utc = datetime.now(pytz.utc) + + # Convert to San Francisco's time zone (PST/PDT) + sf_time_zone = pytz.timezone("America/Los_Angeles") + local_time = current_time_utc.astimezone(sf_time_zone) + + # You may format it as you desire + formatted_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z%z") + + return formatted_time + + +def get_local_time_timezone(timezone="America/Los_Angeles"): + # Get the current time in UTC + current_time_utc = datetime.now(pytz.utc) + + # Convert to San Francisco's time zone (PST/PDT) + sf_time_zone = pytz.timezone(timezone) + local_time = current_time_utc.astimezone(sf_time_zone) + + # You may format it as you desire, including AM/PM + formatted_time = local_time.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") + + return formatted_time + + +def get_local_time(timezone=None): + if timezone is not None: + time_str = get_local_time_timezone(timezone) + else: + # Get the current time, which will be in the local timezone of the computer + local_time = datetime.now().astimezone() + + # You may format it as you desire, including AM/PM + time_str = local_time.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") + + return time_str.strip() + + +def get_utc_time() -> datetime: + """Get the current UTC time""" + # return datetime.now(pytz.utc) + return datetime.now(timezone.utc) + + +def format_datetime(dt): + return dt.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") + + +def validate_date_format(date_str): + """Validate the given date string in the format 'YYYY-MM-DD'.""" + try: + datetime.strptime(date_str, "%Y-%m-%d") + return True + except (ValueError, TypeError): + return False + + +def extract_date_from_timestamp(timestamp): + """Extracts and returns the date from the given timestamp.""" + # Extracts the date (ignoring the time and timezone) + match = re.match(r"(\d{4}-\d{2}-\d{2})", timestamp) + return match.group(1) if match else None + + +def is_utc_datetime(dt: datetime) -> bool: + return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) == timedelta(0) diff --git a/letta/helpers/json_helpers.py b/letta/helpers/json_helpers.py new file mode 100644 index 00000000..3a1af412 --- /dev/null +++ b/letta/helpers/json_helpers.py @@ -0,0 +1,15 @@ +import json +from datetime import datetime + + +def json_loads(data): + return json.loads(data, strict=False) + + +def json_dumps(data, indent=2): + def safe_serializer(obj): + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Type {type(obj)} not serializable") + + return json.dumps(data, indent=indent, default=safe_serializer, ensure_ascii=False) diff --git a/letta/interface.py b/letta/interface.py index 28cb0264..9e0acbd2 100644 --- a/letta/interface.py +++ b/letta/interface.py @@ -5,9 +5,10 @@ from typing import List, Optional from colorama import Fore, Style, init from letta.constants import CLI_WARNING_PREFIX +from letta.helpers.json_helpers import json_loads from letta.local_llm.constants import ASSISTANT_MESSAGE_CLI_SYMBOL, INNER_THOUGHTS_CLI_SYMBOL from letta.schemas.message import Message -from letta.utils import json_loads, printd +from letta.utils import printd init(autoreset=True) diff --git a/letta/llm_api/anthropic.py b/letta/llm_api/anthropic.py index bb5fcf96..7013cbe6 100644 --- a/letta/llm_api/anthropic.py +++ b/letta/llm_api/anthropic.py @@ -18,6 +18,7 @@ from anthropic.types.beta import ( ) from letta.errors import BedrockError, BedrockPermissionError +from letta.helpers.datetime_helpers import get_utc_time from letta.llm_api.aws_bedrock import get_bedrock_client from letta.llm_api.helpers import add_inner_thoughts_to_functions from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION @@ -39,7 +40,6 @@ from letta.schemas.openai.chat_completion_response import MessageDelta, ToolCall from letta.services.provider_manager import ProviderManager from letta.settings import model_settings from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface -from letta.utils import get_utc_time BASE_URL = "https://api.anthropic.com/v1" diff --git a/letta/llm_api/cohere.py b/letta/llm_api/cohere.py index 0259f6fe..5ee818f4 100644 --- a/letta/llm_api/cohere.py +++ b/letta/llm_api/cohere.py @@ -4,6 +4,8 @@ from typing import List, Optional, Union import requests +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps from letta.local_llm.utils import count_tokens from letta.schemas.message import Message from letta.schemas.openai.chat_completion_request import ChatCompletionRequest, Tool @@ -12,7 +14,7 @@ from letta.schemas.openai.chat_completion_response import ( Message as ChoiceMessage, # NOTE: avoid conflict with our own Letta Message datatype ) from letta.schemas.openai.chat_completion_response import ToolCall, UsageStatistics -from letta.utils import get_tool_call_id, get_utc_time, json_dumps, smart_urljoin +from letta.utils import get_tool_call_id, smart_urljoin BASE_URL = "https://api.cohere.ai/v1" diff --git a/letta/llm_api/google_ai.py b/letta/llm_api/google_ai.py index 27b2b88d..6c2fca8c 100644 --- a/letta/llm_api/google_ai.py +++ b/letta/llm_api/google_ai.py @@ -4,12 +4,14 @@ from typing import List, Optional, Tuple import requests from letta.constants import NON_USER_MSG_PREFIX +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps from letta.llm_api.helpers import make_post_request from letta.local_llm.json_parser import clean_json_string_extra_backslash from letta.local_llm.utils import count_tokens from letta.schemas.openai.chat_completion_request import Tool from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, FunctionCall, Message, ToolCall, UsageStatistics -from letta.utils import get_tool_call_id, get_utc_time, json_dumps +from letta.utils import get_tool_call_id def get_gemini_endpoint_and_headers( diff --git a/letta/llm_api/google_vertex.py b/letta/llm_api/google_vertex.py index 9530211f..a8bedcf5 100644 --- a/letta/llm_api/google_vertex.py +++ b/letta/llm_api/google_vertex.py @@ -2,11 +2,13 @@ import uuid from typing import List, Optional from letta.constants import NON_USER_MSG_PREFIX +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps from letta.local_llm.json_parser import clean_json_string_extra_backslash from letta.local_llm.utils import count_tokens from letta.schemas.openai.chat_completion_request import Tool from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, FunctionCall, Message, ToolCall, UsageStatistics -from letta.utils import get_tool_call_id, get_utc_time, json_dumps +from letta.utils import get_tool_call_id def add_dummy_model_messages(messages: List[dict]) -> List[dict]: diff --git a/letta/llm_api/helpers.py b/letta/llm_api/helpers.py index cdb178b9..85ea8e14 100644 --- a/letta/llm_api/helpers.py +++ b/letta/llm_api/helpers.py @@ -7,10 +7,11 @@ from typing import Any, List, Union import requests from letta.constants import OPENAI_CONTEXT_WINDOW_ERROR_SUBSTRING +from letta.helpers.json_helpers import json_dumps from letta.schemas.message import Message from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice from letta.settings import summarizer_settings -from letta.utils import count_tokens, json_dumps, printd +from letta.utils import count_tokens, printd def _convert_to_structured_output_helper(property: dict) -> dict: diff --git a/letta/local_llm/chat_completion_proxy.py b/letta/local_llm/chat_completion_proxy.py index 184489c8..8ba8bfef 100644 --- a/letta/local_llm/chat_completion_proxy.py +++ b/letta/local_llm/chat_completion_proxy.py @@ -6,6 +6,8 @@ import requests from letta.constants import CLI_WARNING_PREFIX from letta.errors import LocalLLMConnectionError, LocalLLMError +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps from letta.local_llm.constants import DEFAULT_WRAPPER from letta.local_llm.function_parser import patch_function from letta.local_llm.grammars.gbnf_grammar_generator import create_dynamic_model_from_function, generate_gbnf_grammar_and_documentation @@ -20,7 +22,7 @@ from letta.local_llm.webui.api import get_webui_completion from letta.local_llm.webui.legacy_api import get_webui_completion as get_webui_completion_legacy from letta.prompts.gpt_summarize import SYSTEM as SUMMARIZE_SYSTEM_MESSAGE from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, Message, ToolCall, UsageStatistics -from letta.utils import get_tool_call_id, get_utc_time, json_dumps +from letta.utils import get_tool_call_id has_shown_warning = False grammar_supported_backends = ["koboldcpp", "llamacpp", "webui", "webui-legacy"] diff --git a/letta/local_llm/function_parser.py b/letta/local_llm/function_parser.py index 0cb79edd..d6636363 100644 --- a/letta/local_llm/function_parser.py +++ b/letta/local_llm/function_parser.py @@ -1,7 +1,7 @@ import copy import json -from letta.utils import json_dumps, json_loads +from letta.helpers.json_helpers import json_dumps, json_loads NO_HEARTBEAT_FUNCS = ["send_message"] diff --git a/letta/local_llm/grammars/gbnf_grammar_generator.py b/letta/local_llm/grammars/gbnf_grammar_generator.py index 402b21bf..8daeda5c 100644 --- a/letta/local_llm/grammars/gbnf_grammar_generator.py +++ b/letta/local_llm/grammars/gbnf_grammar_generator.py @@ -10,7 +10,7 @@ from typing import Any, Callable, List, Optional, Tuple, Type, Union, _GenericAl from docstring_parser import parse from pydantic import BaseModel, create_model -from letta.utils import json_dumps +from letta.helpers.json_helpers import json_dumps class PydanticDataType(Enum): diff --git a/letta/local_llm/json_parser.py b/letta/local_llm/json_parser.py index 35d13656..16771d91 100644 --- a/letta/local_llm/json_parser.py +++ b/letta/local_llm/json_parser.py @@ -2,7 +2,7 @@ import json import re from letta.errors import LLMJSONParsingError -from letta.utils import json_loads +from letta.helpers.json_helpers import json_loads def clean_json_string_extra_backslash(s): diff --git a/letta/local_llm/llm_chat_completion_wrappers/airoboros.py b/letta/local_llm/llm_chat_completion_wrappers/airoboros.py index 42ec63bb..5f076ec8 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/airoboros.py +++ b/letta/local_llm/llm_chat_completion_wrappers/airoboros.py @@ -1,6 +1,5 @@ -from letta.utils import json_dumps, json_loads - from ...errors import LLMJSONParsingError +from ...helpers.json_helpers import json_dumps, json_loads from ..json_parser import clean_json from .wrapper_base import LLMChatCompletionWrapper diff --git a/letta/local_llm/llm_chat_completion_wrappers/chatml.py b/letta/local_llm/llm_chat_completion_wrappers/chatml.py index 2c1ebaf7..62e8a8cf 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/chatml.py +++ b/letta/local_llm/llm_chat_completion_wrappers/chatml.py @@ -1,8 +1,8 @@ from letta.errors import LLMJSONParsingError +from letta.helpers.json_helpers import json_dumps, json_loads from letta.local_llm.json_parser import clean_json from letta.local_llm.llm_chat_completion_wrappers.wrapper_base import LLMChatCompletionWrapper from letta.schemas.enums import MessageRole -from letta.utils import json_dumps, json_loads PREFIX_HINT = """# Reminders: # Important information about yourself and the user is stored in (limited) core memory diff --git a/letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py b/letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py index 19f25668..fa0fa839 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py +++ b/letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py @@ -1,8 +1,7 @@ import yaml -from letta.utils import json_dumps, json_loads - from ...errors import LLMJSONParsingError +from ...helpers.json_helpers import json_dumps, json_loads from ..json_parser import clean_json from .wrapper_base import LLMChatCompletionWrapper diff --git a/letta/local_llm/llm_chat_completion_wrappers/dolphin.py b/letta/local_llm/llm_chat_completion_wrappers/dolphin.py index 575eaf74..6a7f0852 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/dolphin.py +++ b/letta/local_llm/llm_chat_completion_wrappers/dolphin.py @@ -1,6 +1,5 @@ -from letta.utils import json_dumps, json_loads - from ...errors import LLMJSONParsingError +from ...helpers.json_helpers import json_dumps, json_loads from ..json_parser import clean_json from .wrapper_base import LLMChatCompletionWrapper diff --git a/letta/local_llm/llm_chat_completion_wrappers/llama3.py b/letta/local_llm/llm_chat_completion_wrappers/llama3.py index 804e90db..12153209 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/llama3.py +++ b/letta/local_llm/llm_chat_completion_wrappers/llama3.py @@ -1,7 +1,7 @@ from letta.errors import LLMJSONParsingError +from letta.helpers.json_helpers import json_dumps, json_loads from letta.local_llm.json_parser import clean_json from letta.local_llm.llm_chat_completion_wrappers.wrapper_base import LLMChatCompletionWrapper -from letta.utils import json_dumps, json_loads PREFIX_HINT = """# Reminders: # Important information about yourself and the user is stored in (limited) core memory diff --git a/letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py b/letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py index d368f1ec..c69f0960 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py +++ b/letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py @@ -1,5 +1,4 @@ -from letta.utils import json_dumps, json_loads - +from ...helpers.json_helpers import json_dumps, json_loads from .wrapper_base import LLMChatCompletionWrapper diff --git a/letta/local_llm/llm_chat_completion_wrappers/zephyr.py b/letta/local_llm/llm_chat_completion_wrappers/zephyr.py index d230efe5..3b7fb72d 100644 --- a/letta/local_llm/llm_chat_completion_wrappers/zephyr.py +++ b/letta/local_llm/llm_chat_completion_wrappers/zephyr.py @@ -1,6 +1,5 @@ -from letta.utils import json_dumps, json_loads - from ...errors import LLMJSONParsingError +from ...helpers.json_helpers import json_dumps, json_loads from ..json_parser import clean_json from .wrapper_base import LLMChatCompletionWrapper diff --git a/letta/openai_backcompat/openai_object.py b/letta/openai_backcompat/openai_object.py index 8773dedb..f2988d58 100644 --- a/letta/openai_backcompat/openai_object.py +++ b/letta/openai_backcompat/openai_object.py @@ -4,7 +4,7 @@ from copy import deepcopy from enum import Enum from typing import Optional, Tuple, Union -from letta.utils import json_dumps +from letta.helpers.json_helpers import json_dumps api_requestor = None api_resources = None diff --git a/letta/schemas/letta_response.py b/letta/schemas/letta_response.py index ca34a532..acdf1265 100644 --- a/letta/schemas/letta_response.py +++ b/letta/schemas/letta_response.py @@ -5,10 +5,10 @@ from typing import List, Union from pydantic import BaseModel, Field +from letta.helpers.json_helpers import json_dumps from letta.schemas.enums import MessageStreamStatus from letta.schemas.letta_message import LettaMessage, LettaMessageUnion from letta.schemas.usage import LettaUsageStatistics -from letta.utils import json_dumps # TODO: consider moving into own file diff --git a/letta/schemas/message.py b/letta/schemas/message.py index f86e7c15..1e3f4a98 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -12,6 +12,8 @@ from openai.types.chat.chat_completion_message_tool_call import Function as Open from pydantic import BaseModel, Field, field_validator, model_validator from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, TOOL_CALL_ID_MAX_LEN +from letta.helpers.datetime_helpers import get_utc_time, is_utc_datetime +from letta.helpers.json_helpers import json_dumps from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.schemas.enums import MessageContentType, MessageRole from letta.schemas.letta_base import OrmMetadataBase @@ -28,7 +30,6 @@ from letta.schemas.letta_message import ( UserMessage, ) from letta.system import unpack_message -from letta.utils import get_utc_time, is_utc_datetime, json_dumps def add_inner_thoughts_to_tool_call( diff --git a/letta/schemas/organization.py b/letta/schemas/organization.py index f8fc789a..e5452372 100644 --- a/letta/schemas/organization.py +++ b/letta/schemas/organization.py @@ -3,8 +3,9 @@ from typing import Optional from pydantic import Field +from letta.helpers.datetime_helpers import get_utc_time from letta.schemas.letta_base import LettaBase -from letta.utils import create_random_username, get_utc_time +from letta.utils import create_random_username class OrganizationBase(LettaBase): diff --git a/letta/schemas/passage.py b/letta/schemas/passage.py index 74ab8f0c..becdd3c3 100644 --- a/letta/schemas/passage.py +++ b/letta/schemas/passage.py @@ -4,9 +4,9 @@ from typing import Dict, List, Optional from pydantic import Field, field_validator from letta.constants import MAX_EMBEDDING_DIM +from letta.helpers.datetime_helpers import get_utc_time from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.letta_base import OrmMetadataBase -from letta.utils import get_utc_time class PassageBase(OrmMetadataBase): diff --git a/letta/server/rest_api/interface.py b/letta/server/rest_api/interface.py index e58cbde1..037b15c8 100644 --- a/letta/server/rest_api/interface.py +++ b/letta/server/rest_api/interface.py @@ -7,6 +7,7 @@ from datetime import datetime from typing import AsyncGenerator, Literal, Optional, Union from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG +from letta.helpers.datetime_helpers import is_utc_datetime from letta.interface import AgentInterface from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.schemas.enums import MessageStreamStatus @@ -25,7 +26,6 @@ from letta.schemas.message import Message from letta.schemas.openai.chat_completion_response import ChatCompletionChunkResponse from letta.streaming_interface import AgentChunkStreamingInterface from letta.streaming_utils import FunctionArgumentsStreamHandler, JSONInnerThoughtsExtractor -from letta.utils import is_utc_datetime # TODO strip from code / deprecate diff --git a/letta/server/rest_api/routers/v1/tools.py b/letta/server/rest_api/routers/v1/tools.py index 58588d77..91090478 100644 --- a/letta/server/rest_api/routers/v1/tools.py +++ b/letta/server/rest_api/routers/v1/tools.py @@ -8,14 +8,13 @@ from composio.tools.base.abs import InvalidClassDefinition from fastapi import APIRouter, Body, Depends, Header, HTTPException from letta.errors import LettaToolCreateError +from letta.helpers.composio_helpers import get_composio_api_key from letta.log import get_logger from letta.orm.errors import UniqueConstraintViolationError from letta.schemas.letta_message import ToolReturnMessage from letta.schemas.tool import Tool, ToolCreate, ToolRunFromSource, ToolUpdate -from letta.schemas.user import User from letta.server.rest_api.utils import get_letta_server from letta.server.server import SyncServer -from letta.settings import tool_settings router = APIRouter(prefix="/tools", tags=["tools"]) @@ -205,15 +204,18 @@ def run_tool_from_source( # Specific routes for Composio - - @router.get("/composio/apps", response_model=List[AppModel], operation_id="list_composio_apps") def list_composio_apps(server: SyncServer = Depends(get_letta_server), user_id: Optional[str] = Header(None, alias="user_id")): """ Get a list of all Composio apps """ actor = server.user_manager.get_user_or_default(user_id=user_id) - composio_api_key = get_composio_key(server, actor=actor) + composio_api_key = get_composio_api_key(actor=actor, logger=logger) + if not composio_api_key: + raise HTTPException( + status_code=400, # Bad Request + detail=f"No API keys found for Composio. Please add your Composio API Key as an environment variable for your sandbox configuration, or set it as environment variable COMPOSIO_API_KEY.", + ) return server.get_composio_apps(api_key=composio_api_key) @@ -227,7 +229,12 @@ def list_composio_actions_by_app( Get a list of all Composio actions for a specific app """ actor = server.user_manager.get_user_or_default(user_id=user_id) - composio_api_key = get_composio_key(server, actor=actor) + composio_api_key = get_composio_api_key(actor=actor, logger=logger) + if not composio_api_key: + raise HTTPException( + status_code=400, # Bad Request + detail=f"No API keys found for Composio. Please add your Composio API Key as an environment variable for your sandbox configuration, or set it as environment variable COMPOSIO_API_KEY.", + ) return server.get_composio_actions_from_app_name(composio_app_name=composio_app_name, api_key=composio_api_key) @@ -308,24 +315,3 @@ def add_composio_tool( "composio_action_name": composio_action_name, }, ) - - -# TODO: Factor this out to somewhere else -def get_composio_key(server: SyncServer, actor: User): - api_keys = server.sandbox_config_manager.list_sandbox_env_vars_by_key(key="COMPOSIO_API_KEY", actor=actor) - if not api_keys: - logger.warning(f"No API keys found for Composio. Defaulting to the environment variable...") - - if tool_settings.composio_api_key: - return tool_settings.composio_api_key - else: - # Nothing, raise fatal warning - raise HTTPException( - status_code=400, # Bad Request - detail=f"No API keys found for Composio. Please add your Composio API Key as an environment variable for your sandbox configuration, or set it as environment variable COMPOSIO_API_KEY.", - ) - else: - # TODO: Add more protections around this - # Ideally, not tied to a specific sandbox, but for now we just get the first one - # Theoretically possible for someone to have different composio api keys per sandbox - return api_keys[0].value diff --git a/letta/server/server.py b/letta/server/server.py index ac889f55..fabefc7e 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -19,6 +19,8 @@ import letta.system as system from letta.agent import Agent, save_agent from letta.chat_only_agent import ChatOnlyAgent from letta.data_sources.connectors import DataConnector, load_data +from letta.helpers.datetime_helpers import get_utc_time +from letta.helpers.json_helpers import json_dumps, json_loads # TODO use custom interface from letta.interface import AgentInterface # abstract @@ -80,7 +82,7 @@ from letta.services.step_manager import StepManager from letta.services.tool_execution_sandbox import ToolExecutionSandbox from letta.services.tool_manager import ToolManager from letta.services.user_manager import UserManager -from letta.utils import get_friendly_error_msg, get_utc_time, json_dumps, json_loads +from letta.utils import get_friendly_error_msg logger = get_logger(__name__) @@ -296,7 +298,7 @@ class SyncServer(Server): self.tool_manager = ToolManager() self.block_manager = BlockManager() self.source_manager = SourceManager() - self.sandbox_config_manager = SandboxConfigManager(tool_settings) + self.sandbox_config_manager = SandboxConfigManager() self.message_manager = MessageManager() self.job_manager = JobManager() self.agent_manager = AgentManager() @@ -315,7 +317,7 @@ class SyncServer(Server): # Add composio keys to the tool sandbox env vars of the org if tool_settings.composio_api_key: - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() sandbox_config = manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=self.default_user) manager.create_sandbox_env_var( diff --git a/letta/server/ws_api/protocol.py b/letta/server/ws_api/protocol.py index f725b068..c1225b73 100644 --- a/letta/server/ws_api/protocol.py +++ b/letta/server/ws_api/protocol.py @@ -1,4 +1,4 @@ -from letta.utils import json_dumps +from letta.helpers.json_helpers import json_dumps # Server -> client diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 917ff968..4b4d1bc3 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -6,6 +6,7 @@ from sqlalchemy import Select, and_, func, literal, or_, select, union_all from letta.constants import BASE_MEMORY_TOOLS, BASE_TOOLS, MAX_EMBEDDING_DIM, MULTI_AGENT_TOOLS from letta.embeddings import embedding_model +from letta.helpers.datetime_helpers import get_utc_time from letta.log import get_logger from letta.orm import Agent as AgentModel from letta.orm import AgentPassage, AgentsTags @@ -42,7 +43,7 @@ from letta.services.message_manager import MessageManager from letta.services.source_manager import SourceManager from letta.services.tool_manager import ToolManager from letta.settings import settings -from letta.utils import enforce_types, get_utc_time, united_diff +from letta.utils import enforce_types, united_diff logger = get_logger(__name__) diff --git a/letta/services/helpers/agent_manager_helper.py b/letta/services/helpers/agent_manager_helper.py index 95053cd6..8d99449c 100644 --- a/letta/services/helpers/agent_manager_helper.py +++ b/letta/services/helpers/agent_manager_helper.py @@ -4,6 +4,7 @@ from typing import List, Literal, Optional from letta import system from letta.constants import IN_CONTEXT_MEMORY_KEYWORD, STRUCTURED_OUTPUT_MODELS from letta.helpers import ToolRulesSolver +from letta.helpers.datetime_helpers import get_local_time from letta.orm.agent import Agent as AgentModel from letta.orm.agents_tags import AgentsTags from letta.orm.errors import NoResultFound @@ -15,7 +16,6 @@ from letta.schemas.message import Message, MessageCreate, TextContent from letta.schemas.tool_rule import ToolRule from letta.schemas.user import User from letta.system import get_initial_boot_messages, get_login_event -from letta.utils import get_local_time # Static methods diff --git a/letta/services/job_manager.py b/letta/services/job_manager.py index 543c1536..71bb5c0c 100644 --- a/letta/services/job_manager.py +++ b/letta/services/job_manager.py @@ -5,6 +5,7 @@ from typing import List, Literal, Optional, Union from sqlalchemy import select from sqlalchemy.orm import Session +from letta.helpers.datetime_helpers import get_utc_time from letta.orm.enums import JobType from letta.orm.errors import NoResultFound from letta.orm.job import Job as JobModel @@ -20,7 +21,7 @@ from letta.schemas.message import Message as PydanticMessage from letta.schemas.run import Run as PydanticRun from letta.schemas.usage import LettaUsageStatistics from letta.schemas.user import User as PydanticUser -from letta.utils import enforce_types, get_utc_time +from letta.utils import enforce_types class JobManager: diff --git a/letta/services/sandbox_config_manager.py b/letta/services/sandbox_config_manager.py index 6fa10313..1fea2d2a 100644 --- a/letta/services/sandbox_config_manager.py +++ b/letta/services/sandbox_config_manager.py @@ -19,7 +19,7 @@ logger = get_logger(__name__) class SandboxConfigManager: """Manager class to handle business logic related to SandboxConfig and SandboxEnvironmentVariable.""" - def __init__(self, settings): + def __init__(self): from letta.server.server import db_context self.session_maker = db_context diff --git a/letta/services/tool_execution_sandbox.py b/letta/services/tool_execution_sandbox.py index 601fa88c..2dfc55a9 100644 --- a/letta/services/tool_execution_sandbox.py +++ b/letta/services/tool_execution_sandbox.py @@ -62,7 +62,7 @@ class ToolExecutionSandbox: f"Agent attempted to invoke tool {self.tool_name} that does not exist for organization {self.user.organization_id}" ) - self.sandbox_config_manager = SandboxConfigManager(tool_settings) + self.sandbox_config_manager = SandboxConfigManager() self.force_recreate = force_recreate self.force_recreate_venv = force_recreate_venv diff --git a/letta/system.py b/letta/system.py index ab595d13..811b145f 100644 --- a/letta/system.py +++ b/letta/system.py @@ -9,7 +9,8 @@ from .constants import ( INITIAL_BOOT_MESSAGE_SEND_MESSAGE_THOUGHT, MESSAGE_SUMMARY_WARNING_STR, ) -from .utils import get_local_time, json_dumps +from .helpers.datetime_helpers import get_local_time +from .helpers.json_helpers import json_dumps def get_initial_boot_messages(version="startup"): diff --git a/letta/utils.py b/letta/utils.py index d0893bab..b61660e3 100644 --- a/letta/utils.py +++ b/letta/utils.py @@ -4,7 +4,6 @@ import difflib import hashlib import inspect import io -import json import os import pickle import platform @@ -14,14 +13,13 @@ import subprocess import sys import uuid from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from functools import wraps from logging import Logger from typing import Any, Coroutine, List, Union, _GenericAlias, get_args, get_origin, get_type_hints from urllib.parse import urljoin, urlparse import demjson3 as demjson -import pytz import tiktoken from pathvalidate import sanitize_filename as pathvalidate_sanitize_filename @@ -35,6 +33,7 @@ from letta.constants import ( MAX_FILENAME_LENGTH, TOOL_CALL_ID_MAX_LEN, ) +from letta.helpers.json_helpers import json_dumps, json_loads from letta.schemas.openai.chat_completion_response import ChatCompletionResponse DEBUG = False @@ -487,10 +486,6 @@ def smart_urljoin(base_url: str, relative_url: str) -> str: return urljoin(base_url, relative_url) -def is_utc_datetime(dt: datetime) -> bool: - return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) == timedelta(0) - - def get_tool_call_id() -> str: # TODO(sarah) make this a slug-style string? # e.g. OpenAI: "call_xlIfzR1HqAW7xJPa3ExJSg3C" @@ -824,72 +819,6 @@ def united_diff(str1, str2): return "".join(diff) -def parse_formatted_time(formatted_time): - # parse times returned by letta.utils.get_formatted_time() - return datetime.strptime(formatted_time, "%Y-%m-%d %I:%M:%S %p %Z%z") - - -def datetime_to_timestamp(dt): - # convert datetime object to integer timestamp - return int(dt.timestamp()) - - -def timestamp_to_datetime(ts): - # convert integer timestamp to datetime object - return datetime.fromtimestamp(ts) - - -def get_local_time_military(): - # Get the current time in UTC - current_time_utc = datetime.now(pytz.utc) - - # Convert to San Francisco's time zone (PST/PDT) - sf_time_zone = pytz.timezone("America/Los_Angeles") - local_time = current_time_utc.astimezone(sf_time_zone) - - # You may format it as you desire - formatted_time = local_time.strftime("%Y-%m-%d %H:%M:%S %Z%z") - - return formatted_time - - -def get_local_time_timezone(timezone="America/Los_Angeles"): - # Get the current time in UTC - current_time_utc = datetime.now(pytz.utc) - - # Convert to San Francisco's time zone (PST/PDT) - sf_time_zone = pytz.timezone(timezone) - local_time = current_time_utc.astimezone(sf_time_zone) - - # You may format it as you desire, including AM/PM - formatted_time = local_time.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") - - return formatted_time - - -def get_local_time(timezone=None): - if timezone is not None: - time_str = get_local_time_timezone(timezone) - else: - # Get the current time, which will be in the local timezone of the computer - local_time = datetime.now().astimezone() - - # You may format it as you desire, including AM/PM - time_str = local_time.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") - - return time_str.strip() - - -def get_utc_time() -> datetime: - """Get the current UTC time""" - # return datetime.now(pytz.utc) - return datetime.now(timezone.utc) - - -def format_datetime(dt): - return dt.strftime("%Y-%m-%d %I:%M:%S %p %Z%z") - - def parse_json(string) -> dict: """Parse JSON string into JSON with both json and demjson""" result = None @@ -1046,23 +975,6 @@ def get_schema_diff(schema_a, schema_b): return "".join(difference) -# datetime related -def validate_date_format(date_str): - """Validate the given date string in the format 'YYYY-MM-DD'.""" - try: - datetime.strptime(date_str, "%Y-%m-%d") - return True - except (ValueError, TypeError): - return False - - -def extract_date_from_timestamp(timestamp): - """Extracts and returns the date from the given timestamp.""" - # Extracts the date (ignoring the time and timezone) - match = re.match(r"(\d{4}-\d{2}-\d{2})", timestamp) - return match.group(1) if match else None - - def create_uuid_from_string(val: str): """ Generate consistent UUID from a string @@ -1072,19 +984,6 @@ def create_uuid_from_string(val: str): return uuid.UUID(hex=hex_string) -def json_dumps(data, indent=2): - def safe_serializer(obj): - if isinstance(obj, datetime): - return obj.isoformat() - raise TypeError(f"Type {type(obj)} not serializable") - - return json.dumps(data, indent=indent, default=safe_serializer, ensure_ascii=False) - - -def json_loads(data): - return json.loads(data, strict=False) - - def sanitize_filename(filename: str) -> str: """ Sanitize the given filename to prevent directory traversal, invalid characters, diff --git a/paper_experiments/nested_kv_task/nested_kv.py b/paper_experiments/nested_kv_task/nested_kv.py index 04c95ac5..ba6fe565 100644 --- a/paper_experiments/nested_kv_task/nested_kv.py +++ b/paper_experiments/nested_kv_task/nested_kv.py @@ -31,6 +31,7 @@ import openai from icml_experiments.utils import get_experiment_config, load_gzipped_file from tqdm import tqdm +import letta.helpers.json_helpers from letta import utils from letta.cli.cli_config import delete from letta.config import LettaConfig @@ -70,7 +71,7 @@ def archival_memory_text_search(self, query: str, page: Optional[int] = 0) -> Op else: results_pref = f"Showing {len(results)} of {total} results (page {page}/{num_pages}):" results_formatted = [f"memory: {d.text}" for d in results] - results_str = f"{results_pref} {utils.json_dumps(results_formatted)}" + results_str = f"{results_pref} {letta.helpers.json_helpers.json_dumps(results_formatted)}" return results_str diff --git a/tests/conftest.py b/tests/conftest.py index eb261d0f..ffe32c3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,10 @@ import logging import pytest +from letta.services.organization_manager import OrganizationManager +from letta.services.user_manager import UserManager +from letta.settings import tool_settings + def pytest_configure(config): logging.basicConfig(level=logging.DEBUG) @@ -31,3 +35,26 @@ def check_e2b_key_is_set(): original_api_key = tool_settings.e2b_api_key assert original_api_key is not None, "Missing e2b key! Cannot execute these tests." yield + + +@pytest.fixture +def default_organization(): + """Fixture to create and return the default organization.""" + manager = OrganizationManager() + org = manager.create_default_organization() + yield org + + +@pytest.fixture +def default_user(default_organization): + """Fixture to create and return the default user within the default organization.""" + manager = UserManager() + user = manager.create_default_user(org_id=default_organization.id) + yield user + + +@pytest.fixture +def check_composio_key_set(): + original_api_key = tool_settings.composio_api_key + assert original_api_key is not None, "Missing composio key! Cannot execute this test." + yield diff --git a/tests/helpers/endpoints_helper.py b/tests/helpers/endpoints_helper.py index 88c054ec..b59a16af 100644 --- a/tests/helpers/endpoints_helper.py +++ b/tests/helpers/endpoints_helper.py @@ -15,6 +15,7 @@ from letta.config import LettaConfig from letta.constants import DEFAULT_HUMAN, DEFAULT_PERSONA from letta.embeddings import embedding_model from letta.errors import InvalidInnerMonologueError, InvalidToolCallError, MissingInnerMonologueError, MissingToolCallError +from letta.helpers.json_helpers import json_dumps from letta.llm_api.llm_api_tools import create from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.schemas.agent import AgentState @@ -24,7 +25,7 @@ from letta.schemas.letta_response import LettaResponse from letta.schemas.llm_config import LLMConfig from letta.schemas.memory import ChatMemory from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, FunctionCall, Message -from letta.utils import get_human_text, get_persona_text, json_dumps +from letta.utils import get_human_text, get_persona_text from tests.helpers.utils import cleanup # Generate uuid for agent name for this example diff --git a/tests/integration_test_composio.py b/tests/integration_test_composio.py index 8bb61567..98e30373 100644 --- a/tests/integration_test_composio.py +++ b/tests/integration_test_composio.py @@ -1,9 +1,15 @@ import pytest from fastapi.testclient import TestClient +from letta.config import LettaConfig +from letta.constants import COMPOSIO_ENTITY_ENV_VAR_KEY from letta.log import get_logger +from letta.schemas.agent import CreateAgent, UpdateAgent +from letta.schemas.embedding_config import EmbeddingConfig +from letta.schemas.llm_config import LLMConfig +from letta.schemas.tool import ToolCreate from letta.server.rest_api.app import app -from letta.settings import tool_settings +from letta.server.server import SyncServer logger = get_logger(__name__) @@ -13,6 +19,24 @@ def fastapi_client(): return TestClient(app) +@pytest.fixture(scope="module") +def server(): + config = LettaConfig.load() + print("CONFIG PATH", config.config_path) + + config.save() + + server = SyncServer() + return server + + +@pytest.fixture +def composio_gmail_get_profile_tool(server, default_user): + tool_create = ToolCreate.from_composio(action_name="GMAIL_GET_PROFILE") + tool = server.tool_manager.create_or_update_composio_tool(tool_create=tool_create, actor=default_user) + yield tool + + def test_list_composio_apps(fastapi_client): response = fastapi_client.get("/v1/tools/composio/apps") assert response.status_code == 200 @@ -32,28 +56,26 @@ def test_add_composio_tool(fastapi_client): assert "name" in response.json() -def test_composio_version_on_e2b_matches_server(check_e2b_key_is_set): - import composio - from e2b_code_interpreter import Sandbox - from packaging.version import Version - - sbx = Sandbox(tool_settings.e2b_sandbox_template_id) - result = sbx.run_code( - """ - import composio - print(str(composio.__version__)) - """ +def test_composio_tool_execution_e2e(check_composio_key_set, composio_gmail_get_profile_tool, server: SyncServer, default_user): + agent_state = server.agent_manager.create_agent( + agent_create=CreateAgent( + name="sarah_agent", + memory_blocks=[], + llm_config=LLMConfig.default_config("gpt-4o-mini"), + embedding_config=EmbeddingConfig.default_config(provider="openai"), + ), + actor=default_user, ) - e2b_composio_version = result.logs.stdout[0].strip() - composio_version = str(composio.__version__) + agent = server.load_agent(agent_state.id, actor=default_user) + response = agent.execute_tool_and_persist_state(composio_gmail_get_profile_tool.name, {}, composio_gmail_get_profile_tool) + assert response[0]["response_data"]["emailAddress"] == "sarah@letta.com" - # Compare versions - if Version(composio_version) > Version(e2b_composio_version): - raise AssertionError(f"Local composio version {composio_version} is greater than server version {e2b_composio_version}") - elif Version(composio_version) < Version(e2b_composio_version): - logger.warning( - f"Local version of composio {composio_version} is less than the E2B version: {e2b_composio_version}. Please upgrade your local composio version." - ) - - # Print concise summary - logger.info(f"Server version: {composio_version}, E2B version: {e2b_composio_version}") + # Add agent variable changing the entity ID + agent_state = server.agent_manager.update_agent( + agent_id=agent_state.id, + agent_update=UpdateAgent(tool_exec_environment_variables={COMPOSIO_ENTITY_ENV_VAR_KEY: "matt"}), + actor=default_user, + ) + agent = server.load_agent(agent_state.id, actor=default_user) + response = agent.execute_tool_and_persist_state(composio_gmail_get_profile_tool.name, {}, composio_gmail_get_profile_tool) + assert response[0]["response_data"]["emailAddress"] == "matt@letta.com" diff --git a/tests/integration_test_tool_execution_sandbox.py b/tests/integration_test_tool_execution_sandbox.py index ea3e6473..cafa9c1f 100644 --- a/tests/integration_test_tool_execution_sandbox.py +++ b/tests/integration_test_tool_execution_sandbox.py @@ -25,7 +25,7 @@ from letta.schemas.sandbox_config import ( SandboxConfigUpdate, SandboxType, ) -from letta.schemas.tool import Tool, ToolCreate +from letta.schemas.tool import ToolCreate from letta.schemas.user import User from letta.services.organization_manager import OrganizationManager from letta.services.sandbox_config_manager import SandboxConfigManager @@ -53,13 +53,6 @@ def clear_tables(): session.commit() # Commit the deletion -@pytest.fixture -def check_composio_key_set(): - original_api_key = tool_settings.composio_api_key - assert original_api_key is not None, "Missing composio key! Cannot execute this test." - yield - - @pytest.fixture def test_organization(): """Fixture to create and return the default organization.""" @@ -74,6 +67,14 @@ def test_user(test_organization): yield user +@pytest.fixture +def composio_gmail_get_profile_tool(test_user): + tool_manager = ToolManager() + tool_create = ToolCreate.from_composio(action_name="GMAIL_GET_PROFILE") + tool = tool_manager.create_or_update_composio_tool(tool_create=tool_create, actor=test_user) + yield tool + + @pytest.fixture def add_integers_tool(test_user): def add(x: int, y: int) -> int: @@ -194,22 +195,6 @@ def composio_github_star_tool(test_user): yield tool -@pytest.fixture -def composio_gmail_get_profile_tool(test_user): - tool_manager = ToolManager() - tool_create = ToolCreate.from_composio(action_name="GMAIL_GET_PROFILE") - tool = tool_manager.create_or_update_composio_tool(tool_create=tool_create, actor=test_user) - yield tool - - -@pytest.fixture -def composio_gmail_get_profile_tool(test_user): - tool_manager = ToolManager() - tool_create = ToolCreate.from_composio(action_name="GMAIL_GET_PROFILE") - tool = tool_manager.create_or_update_tool(pydantic_tool=Tool(**tool_create.model_dump()), actor=test_user) - yield tool - - @pytest.fixture def clear_core_memory_tool(test_user): def clear_memory(agent_state: "AgentState"): @@ -237,7 +222,7 @@ def agent_state(): agent_state = client.create_agent( memory=ChatMemory(persona="This is the persona", human="My name is Chad"), embedding_config=EmbeddingConfig.default_config(provider="openai"), - llm_config=LLMConfig.default_config(model_name="gpt-4"), + llm_config=LLMConfig.default_config(model_name="gpt-4o-mini"), ) agent_state.tool_rules = [] yield agent_state @@ -255,7 +240,7 @@ def custom_test_sandbox_config(test_user): A tuple containing the SandboxConfigManager and the created sandbox configuration. """ # Create the SandboxConfigManager - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() # Set the sandbox to be within the external codebase path and use a venv external_codebase_path = str(Path(__file__).parent / "test_tool_sandbox" / "restaurant_management_system") @@ -327,7 +312,7 @@ def test_local_sandbox_with_list_rv(mock_e2b_api_key_none, list_tool, test_user) @pytest.mark.local_sandbox def test_local_sandbox_env(mock_e2b_api_key_none, get_env_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() # Make a custom local sandbox config sandbox_dir = str(Path(__file__).parent / "test_tool_sandbox") @@ -353,7 +338,7 @@ def test_local_sandbox_env(mock_e2b_api_key_none, get_env_tool, test_user): @pytest.mark.local_sandbox def test_local_sandbox_per_agent_env(mock_e2b_api_key_none, get_env_tool, agent_state, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() key = "secret_word" # Make a custom local sandbox config @@ -389,7 +374,7 @@ def test_local_sandbox_per_agent_env(mock_e2b_api_key_none, get_env_tool, agent_ @pytest.mark.local_sandbox def test_local_sandbox_e2e_composio_star_github(mock_e2b_api_key_none, check_composio_key_set, composio_github_star_tool, test_user): # Add the composio key - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() config = manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.LOCAL, actor=test_user) manager.create_sandbox_env_var( @@ -482,7 +467,7 @@ def test_local_sandbox_with_venv_errors(mock_e2b_api_key_none, custom_test_sandb @pytest.mark.e2b_sandbox def test_local_sandbox_with_venv_pip_installs_basic(mock_e2b_api_key_none, cowsay_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() config_create = SandboxConfigCreate( config=LocalSandboxConfig(use_venv=True, pip_requirements=[PipRequirement(name="cowsay")]).model_dump() ) @@ -502,7 +487,7 @@ def test_local_sandbox_with_venv_pip_installs_basic(mock_e2b_api_key_none, cowsa @pytest.mark.e2b_sandbox def test_local_sandbox_with_venv_pip_installs_with_update(mock_e2b_api_key_none, cowsay_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() config_create = SandboxConfigCreate(config=LocalSandboxConfig(use_venv=True).model_dump()) config = manager.create_or_update_sandbox_config(config_create, test_user) @@ -554,7 +539,7 @@ def test_e2b_sandbox_default(check_e2b_key_is_set, add_integers_tool, test_user) @pytest.mark.e2b_sandbox def test_e2b_sandbox_pip_installs(check_e2b_key_is_set, cowsay_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() config_create = SandboxConfigCreate(config=E2BSandboxConfig(pip_requirements=["cowsay"]).model_dump()) config = manager.create_or_update_sandbox_config(config_create, test_user) @@ -598,7 +583,7 @@ def test_e2b_sandbox_stateful_tool(check_e2b_key_is_set, clear_core_memory_tool, @pytest.mark.e2b_sandbox def test_e2b_sandbox_inject_env_var_existing_sandbox(check_e2b_key_is_set, get_env_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() config_create = SandboxConfigCreate(config=E2BSandboxConfig().model_dump()) config = manager.create_or_update_sandbox_config(config_create, test_user) @@ -624,7 +609,7 @@ def test_e2b_sandbox_inject_env_var_existing_sandbox(check_e2b_key_is_set, get_e # TODO: There is a near dupe of this test above for local sandbox - we should try to make it parameterized tests to minimize code bloat @pytest.mark.e2b_sandbox def test_e2b_sandbox_per_agent_env(check_e2b_key_is_set, get_env_tool, agent_state, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() key = "secret_word" # Make a custom local sandbox config @@ -659,7 +644,7 @@ def test_e2b_sandbox_per_agent_env(check_e2b_key_is_set, get_env_tool, agent_sta @pytest.mark.e2b_sandbox def test_e2b_sandbox_config_change_force_recreates_sandbox(check_e2b_key_is_set, list_tool, test_user): - manager = SandboxConfigManager(tool_settings) + manager = SandboxConfigManager() old_timeout = 5 * 60 new_timeout = 10 * 60 @@ -693,58 +678,6 @@ def test_e2b_sandbox_with_list_rv(check_e2b_key_is_set, list_tool, test_user): assert len(result.func_return) == 5 -@pytest.mark.e2b_sandbox -def test_e2b_e2e_composio_star_github(check_e2b_key_is_set, check_composio_key_set, composio_github_star_tool, test_user): - # Add the composio key - manager = SandboxConfigManager(tool_settings) - config = manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=test_user) - - manager.create_sandbox_env_var( - SandboxEnvironmentVariableCreate(key="COMPOSIO_API_KEY", value=tool_settings.composio_api_key), - sandbox_config_id=config.id, - actor=test_user, - ) - - result = ToolExecutionSandbox(composio_github_star_tool.name, {"owner": "letta-ai", "repo": "letta"}, user=test_user).run() - assert result.func_return["details"] == "Action executed successfully" - - # Missing args causes error - result = ToolExecutionSandbox(composio_github_star_tool.name, {}, user=test_user).run() - assert "Invalid request data provided" in result.func_return - - -@pytest.mark.e2b_sandbox -def test_e2b_multiple_composio_entities( - check_e2b_key_is_set, check_composio_key_set, composio_gmail_get_profile_tool, agent_state, test_user -): - manager = SandboxConfigManager(tool_settings) - config = manager.get_or_create_default_sandbox_config(sandbox_type=SandboxType.E2B, actor=test_user) - - manager.create_sandbox_env_var( - SandboxEnvironmentVariableCreate(key="COMPOSIO_API_KEY", value=tool_settings.composio_api_key), - sandbox_config_id=config.id, - actor=test_user, - ) - - # Agent state with no composio entity ID - result = ToolExecutionSandbox(composio_gmail_get_profile_tool.name, {}, user=test_user).run(agent_state=agent_state) - assert result.func_return["response_data"]["emailAddress"] == "sarah@letta.com" - - # Agent state with the composio entity set to 'matt' - agent_state.tool_exec_environment_variables = [ - AgentEnvironmentVariable(key=COMPOSIO_ENTITY_ENV_VAR_KEY, value="matt", agent_id=agent_state.id) - ] - result = ToolExecutionSandbox(composio_gmail_get_profile_tool.name, {}, user=test_user).run(agent_state=agent_state) - assert result.func_return["response_data"]["emailAddress"] == "matt@letta.com" - - # Agent state with composio entity ID set to default - agent_state.tool_exec_environment_variables = [ - AgentEnvironmentVariable(key=COMPOSIO_ENTITY_ENV_VAR_KEY, value="default", agent_id=agent_state.id) - ] - result = ToolExecutionSandbox(composio_gmail_get_profile_tool.name, {}, user=test_user).run(agent_state=agent_state) - assert result.func_return["response_data"]["emailAddress"] == "sarah@letta.com" - - # Core memory integration tests class TestCoreMemoryTools: """ diff --git a/tests/test_client_legacy.py b/tests/test_client_legacy.py index d1784da7..997766f9 100644 --- a/tests/test_client_legacy.py +++ b/tests/test_client_legacy.py @@ -12,6 +12,7 @@ from sqlalchemy import delete from letta import create_client from letta.client.client import LocalClient, RESTClient from letta.constants import BASE_MEMORY_TOOLS, BASE_TOOLS, DEFAULT_PRESET, MULTI_AGENT_TOOLS +from letta.helpers.datetime_helpers import get_utc_time from letta.orm import FileMetadata, Source from letta.schemas.agent import AgentState from letta.schemas.embedding_config import EmbeddingConfig @@ -33,7 +34,6 @@ from letta.services.helpers.agent_manager_helper import initialize_message_seque from letta.services.organization_manager import OrganizationManager from letta.services.user_manager import UserManager from letta.settings import model_settings -from letta.utils import get_utc_time from tests.helpers.client_helper import upload_file_using_client # from tests.utils import create_config