From 8de65e1fcdf2751239fb1f48a4d69769b04eef44 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 12 Feb 2025 10:32:38 -0800 Subject: [PATCH 1/8] fix: Refactor listing messages to be much more performant (#963) --- letta/server/server.py | 6 +- letta/services/message_manager.py | 153 ++++++++++++--------- tests/integration_test_agent_tool_graph.py | 14 +- tests/test_managers.py | 11 -- tests/utils.py | 2 +- 5 files changed, 99 insertions(+), 87 deletions(-) diff --git a/letta/server/server.py b/letta/server/server.py index 4a02b74ee5..75ac63bbcc 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -875,14 +875,12 @@ def get_agent_recall( # TODO: Thread actor directly through this function, since the top level caller most likely already retrieved the user actor = self.user_manager.get_user_or_default(user_id=user_id) - start_date = self.message_manager.get_message_by_id(after, actor=actor).created_at if after else None - end_date = self.message_manager.get_message_by_id(before, actor=actor).created_at if before else None records = self.message_manager.list_messages_for_agent( agent_id=agent_id, actor=actor, - start_date=start_date, - end_date=end_date, + after=after, + before=before, limit=limit, ascending=not reverse, ) diff --git a/letta/services/message_manager.py b/letta/services/message_manager.py index ac00ca15a3..01eccb53a1 100644 --- a/letta/services/message_manager.py +++ b/letta/services/message_manager.py @@ -1,6 +1,8 @@ -from datetime import datetime -from typing import Dict, List, Optional +from typing import List, Optional +from sqlalchemy import and_, or_ + +from letta.orm.agent import Agent as AgentModel from letta.orm.errors import NoResultFound from letta.orm.message import Message as MessageModel from letta.schemas.enums import MessageRole @@ -127,44 +129,21 @@ def size( def list_user_messages_for_agent( self, agent_id: str, - actor: Optional[PydanticUser] = None, - before: Optional[str] = None, + actor: PydanticUser, after: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - limit: Optional[int] = 50, - filters: Optional[Dict] = None, + before: Optional[str] = None, query_text: Optional[str] = None, + limit: Optional[int] = 50, ascending: bool = True, ) -> List[PydanticMessage]: - """List user messages with flexible filtering and pagination options. - - Args: - before: Cursor-based pagination - return records before this ID (exclusive) - after: Cursor-based pagination - return records after this ID (exclusive) - start_date: Filter records created after this date - end_date: Filter records created before this date - limit: Maximum number of records to return - filters: Additional filters to apply - query_text: Optional text to search for in message content - - Returns: - List[PydanticMessage] - List of messages matching the criteria - """ - message_filters = {"role": "user"} - if filters: - message_filters.update(filters) - return self.list_messages_for_agent( agent_id=agent_id, actor=actor, - before=before, after=after, - start_date=start_date, - end_date=end_date, - limit=limit, - filters=message_filters, + before=before, query_text=query_text, + role=MessageRole.user, + limit=limit, ascending=ascending, ) @@ -172,48 +151,94 @@ def list_user_messages_for_agent( def list_messages_for_agent( self, agent_id: str, - actor: Optional[PydanticUser] = None, - before: Optional[str] = None, + actor: PydanticUser, after: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - limit: Optional[int] = 50, - filters: Optional[Dict] = None, + before: Optional[str] = None, query_text: Optional[str] = None, + role: Optional[MessageRole] = None, # New parameter for filtering by role + limit: Optional[int] = 50, ascending: bool = True, ) -> List[PydanticMessage]: - """List messages with flexible filtering and pagination options. + """ + Most performant query to list messages for an agent by directly querying the Message table. + + This function filters by the agent_id (leveraging the index on messages.agent_id) + and applies efficient pagination using (created_at, id) as the cursor. + If query_text is provided, it will filter messages whose text content partially matches the query. + If role is provided, it will filter messages by the specified role. Args: - before: Cursor-based pagination - return records before this ID (exclusive) - after: Cursor-based pagination - return records after this ID (exclusive) - start_date: Filter records created after this date - end_date: Filter records created before this date - limit: Maximum number of records to return - filters: Additional filters to apply - query_text: Optional text to search for in message content + agent_id: The ID of the agent whose messages are queried. + actor: The user performing the action (used for permission checks). + after: A message ID; if provided, only messages *after* this message (per sort order) are returned. + before: A message ID; if provided, only messages *before* this message are returned. + query_text: Optional string to partially match the message text content. + role: Optional MessageRole to filter messages by role. + limit: Maximum number of messages to return. + ascending: If True, sort by (created_at, id) ascending; if False, sort descending. Returns: - List[PydanticMessage] - List of messages matching the criteria + List[PydanticMessage]: A list of messages (converted via .to_pydantic()). + + Raises: + NoResultFound: If the provided after/before message IDs do not exist. """ with self.session_maker() as session: - # Start with base filters - message_filters = {"agent_id": agent_id} - if actor: - message_filters.update({"organization_id": actor.organization_id}) - if filters: - message_filters.update(filters) - - results = MessageModel.list( - db_session=session, - before=before, - after=after, - start_date=start_date, - end_date=end_date, - limit=limit, - query_text=query_text, - ascending=ascending, - **message_filters, - ) + # Permission check: raise if the agent doesn't exist or actor is not allowed. + AgentModel.read(db_session=session, identifier=agent_id, actor=actor) + + # Build a query that directly filters the Message table by agent_id. + query = session.query(MessageModel).filter(MessageModel.agent_id == agent_id) + + # If query_text is provided, filter messages by partial match on text. + if query_text: + query = query.filter(MessageModel.text.ilike(f"%{query_text}%")) + + # If role is provided, filter messages by role. + if role: + query = query.filter(MessageModel.role == role.value) # Enum.value ensures comparison is against the string value + + # Apply 'after' pagination if specified. + if after: + after_ref = session.query(MessageModel.created_at, MessageModel.id).filter(MessageModel.id == after).limit(1).one_or_none() + if not after_ref: + raise NoResultFound(f"No message found with id '{after}' for agent '{agent_id}'.") + query = query.filter( + or_( + MessageModel.created_at > after_ref.created_at, + and_( + MessageModel.created_at == after_ref.created_at, + MessageModel.id > after_ref.id, + ), + ) + ) + + # Apply 'before' pagination if specified. + if before: + before_ref = ( + session.query(MessageModel.created_at, MessageModel.id).filter(MessageModel.id == before).limit(1).one_or_none() + ) + if not before_ref: + raise NoResultFound(f"No message found with id '{before}' for agent '{agent_id}'.") + query = query.filter( + or_( + MessageModel.created_at < before_ref.created_at, + and_( + MessageModel.created_at == before_ref.created_at, + MessageModel.id < before_ref.id, + ), + ) + ) + + # Apply ordering based on the ascending flag. + if ascending: + query = query.order_by(MessageModel.created_at.asc(), MessageModel.id.asc()) + else: + query = query.order_by(MessageModel.created_at.desc(), MessageModel.id.desc()) + + # Limit the number of results. + query = query.limit(limit) + # Execute and convert each Message to its Pydantic representation. + results = query.all() return [msg.to_pydantic() for msg in results] diff --git a/tests/integration_test_agent_tool_graph.py b/tests/integration_test_agent_tool_graph.py index 025f751b48..97b8709f2e 100644 --- a/tests/integration_test_agent_tool_graph.py +++ b/tests/integration_test_agent_tool_graph.py @@ -186,7 +186,7 @@ def test_check_tool_rules_with_different_models(mock_e2b_api_key_none): client = create_client() config_files = [ - "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json", + "tests/configs/llm_model_configs/claude-3-5-sonnet.json", "tests/configs/llm_model_configs/openai-gpt-3.5-turbo.json", "tests/configs/llm_model_configs/openai-gpt-4o.json", ] @@ -247,7 +247,7 @@ def test_claude_initial_tool_rule_enforced(mock_e2b_api_key_none): tools = [t1, t2] # Make agent state - anthropic_config_file = "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json" + anthropic_config_file = "tests/configs/llm_model_configs/claude-3-5-sonnet.json" for i in range(3): agent_uuid = str(uuid.uuid4()) agent_state = setup_agent( @@ -299,7 +299,7 @@ def test_agent_no_structured_output_with_one_child_tool(mock_e2b_api_key_none): tools = [send_message, archival_memory_search, archival_memory_insert] config_files = [ - "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json", + "tests/configs/llm_model_configs/claude-3-5-sonnet.json", "tests/configs/llm_model_configs/openai-gpt-4o.json", ] @@ -383,7 +383,7 @@ def test_agent_conditional_tool_easy(mock_e2b_api_key_none): ] tools = [flip_coin_tool, reveal_secret] - config_file = "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json" + config_file = "tests/configs/llm_model_configs/claude-3-5-sonnet.json" agent_state = setup_agent(client, config_file, agent_uuid=agent_uuid, tool_ids=[t.id for t in tools], tool_rules=tool_rules) response = client.user_message(agent_id=agent_state.id, message="flip a coin until you get the secret word") @@ -455,7 +455,7 @@ def test_agent_conditional_tool_hard(mock_e2b_api_key_none): # Setup agent with all tools tools = [play_game_tool, flip_coin_tool, reveal_secret] - config_file = "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json" + config_file = "tests/configs/llm_model_configs/claude-3-5-sonnet.json" agent_state = setup_agent(client, config_file, agent_uuid=agent_uuid, tool_ids=[t.id for t in tools], tool_rules=tool_rules) # Ask agent to try to get all secret words @@ -681,7 +681,7 @@ def test_init_tool_rule_always_fails_one_tool(): ) # Set up agent with the tool rule - claude_config = "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json" + claude_config = "tests/configs/llm_model_configs/claude-3-5-sonnet.json" agent_state = setup_agent(client, claude_config, agent_uuid, tool_rules=[tool_rule], tool_ids=[bad_tool.id], include_base_tools=False) # Start conversation @@ -710,7 +710,7 @@ def test_init_tool_rule_always_fails_multiple_tools(): ) # Set up agent with the tool rule - claude_config = "tests/configs/llm_model_configs/claude-3-sonnet-20240229.json" + claude_config = "tests/configs/llm_model_configs/claude-3-5-sonnet.json" agent_state = setup_agent(client, claude_config, agent_uuid, tool_rules=[tool_rule], tool_ids=[bad_tool.id], include_base_tools=True) # Start conversation diff --git a/tests/test_managers.py b/tests/test_managers.py index 43ffbaa767..540717dc35 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -1971,17 +1971,6 @@ def test_message_listing_text_search(server: SyncServer, hello_world_message_fix assert len(search_results) == 0 -def test_message_listing_date_range_filtering(server: SyncServer, hello_world_message_fixture, default_user, sarah_agent): - """Test filtering messages by date range""" - create_test_messages(server, hello_world_message_fixture, default_user) - now = datetime.utcnow() - - date_results = server.message_manager.list_user_messages_for_agent( - agent_id=sarah_agent.id, actor=default_user, start_date=now - timedelta(minutes=1), end_date=now + timedelta(minutes=1), limit=10 - ) - assert len(date_results) > 0 - - # ====================================================================================================================== # Block Manager Tests # ====================================================================================================================== diff --git a/tests/utils.py b/tests/utils.py index 46d83ed753..e16cd15aa7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -164,7 +164,7 @@ def wait_for_incoming_message( deadline = time.time() + max_wait_seconds while time.time() < deadline: - messages = client.server.message_manager.list_messages_for_agent(agent_id=agent_id) + messages = client.server.message_manager.list_messages_for_agent(agent_id=agent_id, actor=client.user) # Check for the system message containing `substring` if any(message.role == MessageRole.system and substring in (message.text or "") for message in messages): return True From 6227c057d7808814c073edeaabbb9d75e65d2a79 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 12 Feb 2025 13:27:13 -0800 Subject: [PATCH 2/8] feat: Add telemetry logging around agent / multi-agent broadcasting (#971) --- letta/agent.py | 19 ++++++++++++++++--- letta/functions/helpers.py | 29 ++++++++++++++++++++++++++++- letta/settings.py | 3 +++ letta/utils.py | 17 +++++++++++++++++ tests/test_google_embeddings.py | 1 - 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/letta/agent.py b/letta/agent.py index 9a8ce75857..bad40be373 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -61,6 +61,7 @@ get_utc_time, json_dumps, json_loads, + log_telemetry, parse_json, printd, validate_function_response, @@ -306,7 +307,7 @@ def _get_ai_reply( last_function_failed: bool = False, ) -> ChatCompletionResponse: """Get response from LLM API with robust retry mechanism.""" - + log_telemetry(self.logger, "_get_ai_reply start") allowed_tool_names = self.tool_rules_solver.get_allowed_tool_names(last_function_response=self.last_function_response) agent_state_tool_jsons = [t.json_schema for t in self.agent_state.tools] @@ -337,6 +338,7 @@ def _get_ai_reply( for attempt in range(1, empty_response_retry_limit + 1): try: + log_telemetry(self.logger, "_get_ai_reply create start") response = create( llm_config=self.agent_state.llm_config, messages=message_sequence, @@ -349,6 +351,7 @@ def _get_ai_reply( stream=stream, stream_interface=self.interface, ) + log_telemetry(self.logger, "_get_ai_reply create finish") # These bottom two are retryable if len(response.choices) == 0 or response.choices[0] is None: @@ -360,12 +363,13 @@ def _get_ai_reply( raise RuntimeError("Finish reason was length (maximum context length)") else: raise ValueError(f"Bad finish reason from API: {response.choices[0].finish_reason}") - + log_telemetry(self.logger, "_handle_ai_response finish") return response except ValueError as ve: if attempt >= empty_response_retry_limit: warnings.warn(f"Retry limit reached. Final error: {ve}") + log_telemetry(self.logger, "_handle_ai_response finish ValueError") raise Exception(f"Retries exhausted and no valid response received. Final error: {ve}") else: delay = min(backoff_factor * (2 ** (attempt - 1)), max_delay) @@ -374,8 +378,10 @@ def _get_ai_reply( except Exception as e: # For non-retryable errors, exit immediately + log_telemetry(self.logger, "_handle_ai_response finish generic Exception") raise e + log_telemetry(self.logger, "_handle_ai_response finish catch-all exception") raise Exception("Retries exhausted and no valid response received.") def _handle_ai_response( @@ -388,7 +394,7 @@ def _handle_ai_response( response_message_id: Optional[str] = None, ) -> Tuple[List[Message], bool, bool]: """Handles parsing and function execution""" - + log_telemetry(self.logger, "_handle_ai_response start") # Hacky failsafe for now to make sure we didn't implement the streaming Message ID creation incorrectly if response_message_id is not None: assert response_message_id.startswith("message-"), response_message_id @@ -506,7 +512,13 @@ def _handle_ai_response( self.interface.function_message(f"Running {function_name}({function_args})", msg_obj=messages[-1]) try: # handle tool execution (sandbox) and state updates + log_telemetry( + self.logger, "_handle_ai_response execute tool start", function_name=function_name, function_args=function_args + ) function_response, sandbox_run_result = self.execute_tool_and_persist_state(function_name, function_args, target_letta_tool) + log_telemetry( + self.logger, "_handle_ai_response execute tool finish", function_name=function_name, function_args=function_args + ) if sandbox_run_result and sandbox_run_result.status == "error": messages = self._handle_function_error_response( @@ -597,6 +609,7 @@ def _handle_ai_response( elif self.tool_rules_solver.is_terminal_tool(function_name): heartbeat_request = False + log_telemetry(self.logger, "_handle_ai_response finish") return messages, heartbeat_request, function_failed def step( diff --git a/letta/functions/helpers.py b/letta/functions/helpers.py index 92b75e4928..ef42b4c9ec 100644 --- a/letta/functions/helpers.py +++ b/letta/functions/helpers.py @@ -17,6 +17,7 @@ from letta.schemas.user import User from letta.server.rest_api.utils import get_letta_server from letta.settings import settings +from letta.utils import log_telemetry # TODO: This is kind of hacky, as this is used to search up the action later on composio's side @@ -341,10 +342,16 @@ async def async_send_message_with_retries( timeout: int, logging_prefix: Optional[str] = None, ) -> str: - logging_prefix = logging_prefix or "[async_send_message_with_retries]" + log_telemetry(sender_agent.logger, f"async_send_message_with_retries start", target_agent_id=target_agent_id) + for attempt in range(1, max_retries + 1): try: + log_telemetry( + sender_agent.logger, + f"async_send_message_with_retries -> asyncio wait for send_message_to_agent_no_stream start", + target_agent_id=target_agent_id, + ) response = await asyncio.wait_for( send_message_to_agent_no_stream( server=server, @@ -354,15 +361,24 @@ async def async_send_message_with_retries( ), timeout=timeout, ) + log_telemetry( + sender_agent.logger, + f"async_send_message_with_retries -> asyncio wait for send_message_to_agent_no_stream finish", + target_agent_id=target_agent_id, + ) # Then parse out the assistant message assistant_message = parse_letta_response_for_assistant_message(target_agent_id, response) if assistant_message: sender_agent.logger.info(f"{logging_prefix} - {assistant_message}") + log_telemetry( + sender_agent.logger, f"async_send_message_with_retries finish with assistant message", target_agent_id=target_agent_id + ) return assistant_message else: msg = f"(No response from agent {target_agent_id})" sender_agent.logger.info(f"{logging_prefix} - {msg}") + log_telemetry(sender_agent.logger, f"async_send_message_with_retries finish no response", target_agent_id=target_agent_id) return msg except asyncio.TimeoutError: @@ -380,6 +396,12 @@ async def async_send_message_with_retries( await asyncio.sleep(backoff) else: sender_agent.logger.error(f"{logging_prefix} - Fatal error: {error_msg}") + log_telemetry( + sender_agent.logger, + f"async_send_message_with_retries finish fatal error", + target_agent_id=target_agent_id, + error_msg=error_msg, + ) raise Exception(error_msg) @@ -468,6 +490,7 @@ def runner(): async def _send_message_to_agents_matching_all_tags_async(sender_agent: "Agent", message: str, tags: List[str]) -> List[str]: + log_telemetry(sender_agent.logger, "_send_message_to_agents_matching_all_tags_async start", message=message, tags=tags) server = get_letta_server() augmented_message = ( @@ -477,7 +500,9 @@ async def _send_message_to_agents_matching_all_tags_async(sender_agent: "Agent", ) # Retrieve up to 100 matching agents + log_telemetry(sender_agent.logger, "_send_message_to_agents_matching_all_tags_async listing agents start", message=message, tags=tags) matching_agents = server.agent_manager.list_agents(actor=sender_agent.user, tags=tags, match_all_tags=True, limit=100) + log_telemetry(sender_agent.logger, "_send_message_to_agents_matching_all_tags_async listing agents finish", message=message, tags=tags) # Create a system message messages = [MessageCreate(role=MessageRole.system, content=augmented_message, name=sender_agent.agent_state.name)] @@ -504,4 +529,6 @@ async def _send_single(agent_state): final.append(str(r)) else: final.append(r) + + log_telemetry(sender_agent.logger, "_send_message_to_agents_matching_all_tags_async finish", message=message, tags=tags) return final diff --git a/letta/settings.py b/letta/settings.py index 667f724265..4fda300c2e 100644 --- a/letta/settings.py +++ b/letta/settings.py @@ -151,6 +151,9 @@ class Settings(BaseSettings): multi_agent_send_message_timeout: int = 20 * 60 multi_agent_concurrent_sends: int = 15 + # telemetry logging + verbose_telemetry_logging: bool = False + @property def letta_pg_uri(self) -> str: if self.pg_uri: diff --git a/letta/utils.py b/letta/utils.py index 171391e37d..d0893bab4f 100644 --- a/letta/utils.py +++ b/letta/utils.py @@ -16,6 +16,7 @@ from contextlib import contextmanager from datetime import datetime, timedelta, 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 @@ -1150,3 +1151,19 @@ def run_async_task(coro: Coroutine[Any, Any, Any]) -> Any: except RuntimeError: # If no event loop is running, create a new one return asyncio.run(coro) + + +def log_telemetry(logger: Logger, event: str, **kwargs): + """ + Logs telemetry events with a timestamp. + + :param logger: A logger + :param event: A string describing the event. + :param kwargs: Additional key-value pairs for logging metadata. + """ + from letta.settings import settings + + if settings.verbose_telemetry_logging: + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S,%f UTC") # More readable timestamp + extra_data = " | ".join(f"{key}={value}" for key, value in kwargs.items() if value is not None) + logger.info(f"[{timestamp}] EVENT: {event} | {extra_data}") diff --git a/tests/test_google_embeddings.py b/tests/test_google_embeddings.py index 71570ff0aa..dcaad59663 100644 --- a/tests/test_google_embeddings.py +++ b/tests/test_google_embeddings.py @@ -8,7 +8,6 @@ import os import threading import time -import uuid import pytest from letta_client import CreateBlock From c8ce21d2709cd42c50c2020b3dd191c40d70801d Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 12 Feb 2025 15:06:56 -0800 Subject: [PATCH 3/8] feat: Add `message_buffer_autoclear` field to Agent (#978) --- ...a08_add_stateless_option_for_agentstate.py | 36 +++++++++++++++++++ letta/orm/agent.py | 8 ++++- letta/schemas/agent.py | 14 ++++++++ letta/services/agent_manager.py | 4 +++ tests/helpers/utils.py | 4 +++ tests/test_managers.py | 2 ++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/7980d239ea08_add_stateless_option_for_agentstate.py diff --git a/alembic/versions/7980d239ea08_add_stateless_option_for_agentstate.py b/alembic/versions/7980d239ea08_add_stateless_option_for_agentstate.py new file mode 100644 index 0000000000..9693940d74 --- /dev/null +++ b/alembic/versions/7980d239ea08_add_stateless_option_for_agentstate.py @@ -0,0 +1,36 @@ +"""Add message_buffer_autoclear option for AgentState + +Revision ID: 7980d239ea08 +Revises: dfafcf8210ca +Create Date: 2025-02-12 14:02:00.918226 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "7980d239ea08" +down_revision: Union[str, None] = "dfafcf8210ca" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add the column with a temporary nullable=True so we can backfill + op.add_column("agents", sa.Column("message_buffer_autoclear", sa.Boolean(), nullable=True)) + + # Backfill existing rows to set message_buffer_autoclear to False where it's NULL + op.execute("UPDATE agents SET message_buffer_autoclear = false WHERE message_buffer_autoclear IS NULL") + + # Now, enforce nullable=False after backfilling + op.alter_column("agents", "message_buffer_autoclear", nullable=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("agents", "message_buffer_autoclear") + # ### end Alembic commands ### diff --git a/letta/orm/agent.py b/letta/orm/agent.py index a4d08f719c..07b3917b80 100644 --- a/letta/orm/agent.py +++ b/letta/orm/agent.py @@ -1,7 +1,7 @@ import uuid from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import JSON, Index, String +from sqlalchemy import JSON, Boolean, Index, String from sqlalchemy.orm import Mapped, mapped_column, relationship from letta.orm.block import Block @@ -62,6 +62,11 @@ class Agent(SqlalchemyBase, OrganizationMixin): # Tool rules tool_rules: Mapped[Optional[List[ToolRule]]] = mapped_column(ToolRulesColumn, doc="the tool rules for this agent.") + # Stateless + message_buffer_autoclear: Mapped[bool] = mapped_column( + Boolean, doc="If set to True, the agent will not remember previous messages. Not recommended unless you have an advanced use case." + ) + # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="agents") tool_exec_environment_variables: Mapped[List["AgentEnvironmentVariable"]] = relationship( @@ -146,6 +151,7 @@ def to_pydantic(self) -> PydanticAgentState: "project_id": self.project_id, "template_id": self.template_id, "base_template_id": self.base_template_id, + "message_buffer_autoclear": self.message_buffer_autoclear, } return self.__pydantic_model__(**state) diff --git a/letta/schemas/agent.py b/letta/schemas/agent.py index 9269742deb..d1c9d8b835 100644 --- a/letta/schemas/agent.py +++ b/letta/schemas/agent.py @@ -85,6 +85,12 @@ class AgentState(OrmMetadataBase, validate_assignment=True): template_id: Optional[str] = Field(None, description="The id of the template the agent belongs to.") base_template_id: Optional[str] = Field(None, description="The base template id of the agent.") + # An advanced configuration that makes it so this agent does not remember any previous messages + message_buffer_autoclear: bool = Field( + False, + description="If set to True, the agent will not remember previous messages (though the agent will still retain state via core memory blocks and archival/recall memory). Not recommended unless you have an advanced use case.", + ) + def get_agent_env_vars_as_dict(self) -> Dict[str, str]: # Get environment variables for this agent specifically per_agent_env_vars = {} @@ -146,6 +152,10 @@ class CreateAgent(BaseModel, validate_assignment=True): # project_id: Optional[str] = Field(None, description="The id of the project the agent belongs to.") template_id: Optional[str] = Field(None, description="The id of the template the agent belongs to.") base_template_id: Optional[str] = Field(None, description="The base template id of the agent.") + message_buffer_autoclear: bool = Field( + False, + description="If set to True, the agent will not remember previous messages (though the agent will still retain state via core memory blocks and archival/recall memory). Not recommended unless you have an advanced use case.", + ) @field_validator("name") @classmethod @@ -216,6 +226,10 @@ class UpdateAgent(BaseModel): project_id: Optional[str] = Field(None, description="The id of the project the agent belongs to.") template_id: Optional[str] = Field(None, description="The id of the template the agent belongs to.") base_template_id: Optional[str] = Field(None, description="The base template id of the agent.") + message_buffer_autoclear: Optional[bool] = Field( + None, + description="If set to True, the agent will not remember previous messages (though the agent will still retain state via core memory blocks and archival/recall memory). Not recommended unless you have an advanced use case.", + ) class Config: extra = "ignore" # Ignores extra fields diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 3c965386e0..3024123d7c 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -123,6 +123,7 @@ def create_agent( project_id=agent_create.project_id, template_id=agent_create.template_id, base_template_id=agent_create.base_template_id, + message_buffer_autoclear=agent_create.message_buffer_autoclear, ) # If there are provided environment variables, add them in @@ -185,6 +186,7 @@ def _create_agent( project_id: Optional[str] = None, template_id: Optional[str] = None, base_template_id: Optional[str] = None, + message_buffer_autoclear: bool = False, ) -> PydanticAgentState: """Create a new agent.""" with self.session_maker() as session: @@ -202,6 +204,7 @@ def _create_agent( "project_id": project_id, "template_id": template_id, "base_template_id": base_template_id, + "message_buffer_autoclear": message_buffer_autoclear, } # Create the new agent using SqlalchemyBase.create @@ -263,6 +266,7 @@ def _update_agent(self, agent_id: str, agent_update: UpdateAgent, actor: Pydanti "project_id", "template_id", "base_template_id", + "message_buffer_autoclear", } for field in scalar_fields: value = getattr(agent_update, field, None) diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 167a39ee3f..f4868fda04 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -151,3 +151,7 @@ def comprehensive_agent_checks(agent: AgentState, request: Union[CreateAgent, Up assert all( any(rule.tool_name == req_rule.tool_name for rule in agent.tool_rules) for req_rule in request.tool_rules ), f"Tool rules mismatch: {agent.tool_rules} != {request.tool_rules}" + + # Assert message_buffer_autoclear + if not request.message_buffer_autoclear is None: + assert agent.message_buffer_autoclear == request.message_buffer_autoclear diff --git a/tests/test_managers.py b/tests/test_managers.py index 540717dc35..cce3e449e9 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -447,6 +447,7 @@ def comprehensive_test_agent_fixture(server: SyncServer, default_user, print_too tool_rules=[InitToolRule(tool_name=print_tool.name)], initial_message_sequence=[MessageCreate(role=MessageRole.user, content="hello world")], tool_exec_environment_variables={"test_env_var_key_a": "test_env_var_value_a", "test_env_var_key_b": "test_env_var_value_b"}, + message_buffer_autoclear=True, ) created_agent = server.agent_manager.create_agent( create_agent_request, @@ -601,6 +602,7 @@ def test_update_agent(server: SyncServer, comprehensive_test_agent_fixture, othe message_ids=["10", "20"], metadata={"train_key": "train_value"}, tool_exec_environment_variables={"test_env_var_key_a": "a", "new_tool_exec_key": "n"}, + message_buffer_autoclear=False, ) last_updated_timestamp = agent.updated_at From 310526878376b18ca4b7115e5f45786319afeddd Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 12 Feb 2025 16:36:16 -0800 Subject: [PATCH 4/8] fix: drop name param for anthropic messages (#982) --- letta/schemas/message.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/letta/schemas/message.py b/letta/schemas/message.py index 722a749b10..f86e7c1534 100644 --- a/letta/schemas/message.py +++ b/letta/schemas/message.py @@ -570,19 +570,12 @@ def add_xml_tag(string: str, xml_tag: Optional[str]): "role": "user", } - # Optional field, do not include if null - if self.name is not None: - anthropic_message["name"] = self.name - elif self.role == "user": assert all([v is not None for v in [self.text, self.role]]), vars(self) anthropic_message = { "content": self.text, "role": self.role, } - # Optional field, do not include if null - if self.name is not None: - anthropic_message["name"] = self.name elif self.role == "assistant": assert self.tool_calls is not None or self.text is not None @@ -624,10 +617,6 @@ def add_xml_tag(string: str, xml_tag: Optional[str]): # TODO support multi-modal anthropic_message["content"] = content - # Optional fields, do not include if null - if self.name is not None: - anthropic_message["name"] = self.name - elif self.role == "tool": # NOTE: Anthropic uses role "user" for "tool" responses assert all([v is not None for v in [self.role, self.tool_call_id]]), vars(self) From b951b2cf6f7d367afa6fe219738785b04cc15342 Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 12 Feb 2025 16:45:18 -0800 Subject: [PATCH 5/8] fix: catch tool parsing errors in api layer (#983) --- letta/server/rest_api/routers/v1/tools.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/letta/server/rest_api/routers/v1/tools.py b/letta/server/rest_api/routers/v1/tools.py index 912503fe64..58588d7720 100644 --- a/letta/server/rest_api/routers/v1/tools.py +++ b/letta/server/rest_api/routers/v1/tools.py @@ -124,6 +124,10 @@ def upsert_tool( # Log the error and raise a conflict exception print(f"Unique constraint violation occurred: {e}") raise HTTPException(status_code=409, detail=str(e)) + except LettaToolCreateError as e: + # HTTP 400 == Bad Request + print(f"Error occurred during tool upsert: {e}") + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: # Catch other unexpected errors and raise an internal server error print(f"Unexpected error occurred: {e}") @@ -140,8 +144,17 @@ def modify_tool( """ Update an existing tool """ - actor = server.user_manager.get_user_or_default(user_id=user_id) - return server.tool_manager.update_tool_by_id(tool_id=tool_id, tool_update=request, actor=actor) + try: + actor = server.user_manager.get_user_or_default(user_id=user_id) + return server.tool_manager.update_tool_by_id(tool_id=tool_id, tool_update=request, actor=actor) + except LettaToolCreateError as e: + # HTTP 400 == Bad Request + print(f"Error occurred during tool update: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Catch other unexpected errors and raise an internal server error + print(f"Unexpected error occurred: {e}") + raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {str(e)}") @router.post("/add-base-tools", response_model=List[Tool], operation_id="add_base_tools") From db216b7e9182a6715bd6f5298704e0d0973b29b0 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 12 Feb 2025 17:13:07 -0800 Subject: [PATCH 6/8] feat: Add logic for "stateless" agents (#984) --- letta/agent.py | 3 + letta/client/client.py | 5 + letta/schemas/agent.py | 1 - letta/services/agent_manager.py | 1 + tests/integration_test_multi_agent.py | 328 ++++++++++++++++++++++++++ tests/test_base_functions.py | 260 -------------------- 6 files changed, 337 insertions(+), 261 deletions(-) create mode 100644 tests/integration_test_multi_agent.py diff --git a/letta/agent.py b/letta/agent.py index bad40be373..5202bac25b 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -697,6 +697,9 @@ def step( else: break + if self.agent_state.message_buffer_autoclear: + self.agent_manager.trim_all_in_context_messages_except_system(self.agent_state.id, actor=self.user) + return LettaUsageStatistics(**total_usage.model_dump(), step_count=step_count) def inner_step( diff --git a/letta/client/client.py b/letta/client/client.py index 485cc6f9c5..ed7a322080 100644 --- a/letta/client/client.py +++ b/letta/client/client.py @@ -73,6 +73,7 @@ def create_agent( metadata: Optional[Dict] = {"human:": DEFAULT_HUMAN, "persona": DEFAULT_PERSONA}, description: Optional[str] = None, tags: Optional[List[str]] = None, + message_buffer_autoclear: bool = False, ) -> AgentState: raise NotImplementedError @@ -540,6 +541,7 @@ def create_agent( description: Optional[str] = None, initial_message_sequence: Optional[List[Message]] = None, tags: Optional[List[str]] = None, + message_buffer_autoclear: bool = False, ) -> AgentState: """Create an agent @@ -600,6 +602,7 @@ def create_agent( "initial_message_sequence": initial_message_sequence, "tags": tags, "include_base_tools": include_base_tools, + "message_buffer_autoclear": message_buffer_autoclear, } # Only add name if it's not None @@ -2353,6 +2356,7 @@ def create_agent( description: Optional[str] = None, initial_message_sequence: Optional[List[Message]] = None, tags: Optional[List[str]] = None, + message_buffer_autoclear: bool = False, ) -> AgentState: """Create an agent @@ -2404,6 +2408,7 @@ def create_agent( "embedding_config": embedding_config if embedding_config else self._default_embedding_config, "initial_message_sequence": initial_message_sequence, "tags": tags, + "message_buffer_autoclear": message_buffer_autoclear, } # Only add name if it's not None diff --git a/letta/schemas/agent.py b/letta/schemas/agent.py index d1c9d8b835..032b9aab7a 100644 --- a/letta/schemas/agent.py +++ b/letta/schemas/agent.py @@ -43,7 +43,6 @@ class AgentState(OrmMetadataBase, validate_assignment=True): system (str): The system prompt used by the agent. llm_config (LLMConfig): The LLM configuration used by the agent. embedding_config (EmbeddingConfig): The embedding configuration used by the agent. - """ __id_prefix__ = "agent" diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 3024123d7c..917ff9688b 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -498,6 +498,7 @@ def trim_older_in_context_messages(self, num: int, agent_id: str, actor: Pydanti @enforce_types def trim_all_in_context_messages_except_system(self, agent_id: str, actor: PydanticUser) -> PydanticAgentState: message_ids = self.get_agent_by_id(agent_id=agent_id, actor=actor).message_ids + # TODO: How do we know this? new_messages = [message_ids[0]] # 0 is system message return self._set_in_context_messages(agent_id=agent_id, message_ids=new_messages, actor=actor) diff --git a/tests/integration_test_multi_agent.py b/tests/integration_test_multi_agent.py new file mode 100644 index 0000000000..d0b0edb350 --- /dev/null +++ b/tests/integration_test_multi_agent.py @@ -0,0 +1,328 @@ +import json + +import pytest + +from letta import LocalClient, create_client +from letta.functions.functions import derive_openai_json_schema, parse_source_code +from letta.orm import Base +from letta.schemas.embedding_config import EmbeddingConfig +from letta.schemas.letta_message import SystemMessage, ToolReturnMessage +from letta.schemas.llm_config import LLMConfig +from letta.schemas.memory import ChatMemory +from letta.schemas.tool import Tool +from tests.helpers.utils import retry_until_success +from tests.utils import wait_for_incoming_message + + +@pytest.fixture(autouse=True) +def truncate_database(): + from letta.server.server import db_context + + with db_context() as session: + for table in reversed(Base.metadata.sorted_tables): # Reverse to avoid FK issues + session.execute(table.delete()) # Truncate table + session.commit() + + +@pytest.fixture(scope="function") +def client(): + client = create_client() + client.set_default_llm_config(LLMConfig.default_config("gpt-4o")) + client.set_default_embedding_config(EmbeddingConfig.default_config(provider="openai")) + + yield client + + +@pytest.fixture(scope="function") +def agent_obj(client: LocalClient): + """Create a test agent that we can call functions on""" + send_message_to_agent_and_wait_for_reply_tool_id = client.get_tool_id(name="send_message_to_agent_and_wait_for_reply") + agent_state = client.create_agent(tool_ids=[send_message_to_agent_and_wait_for_reply_tool_id]) + + agent_obj = client.server.load_agent(agent_id=agent_state.id, actor=client.user) + yield agent_obj + + # client.delete_agent(agent_obj.agent_state.id) + + +@pytest.fixture(scope="function") +def other_agent_obj(client: LocalClient): + """Create another test agent that we can call functions on""" + agent_state = client.create_agent(include_multi_agent_tools=False) + + other_agent_obj = client.server.load_agent(agent_id=agent_state.id, actor=client.user) + yield other_agent_obj + + client.delete_agent(other_agent_obj.agent_state.id) + + +@pytest.fixture +def roll_dice_tool(client): + def roll_dice(): + """ + Rolls a 6 sided die. + + Returns: + str: The roll result. + """ + return "Rolled a 5!" + + # Set up tool details + source_code = parse_source_code(roll_dice) + source_type = "python" + description = "test_description" + tags = ["test"] + + tool = Tool(description=description, tags=tags, source_code=source_code, source_type=source_type) + derived_json_schema = derive_openai_json_schema(source_code=tool.source_code, name=tool.name) + + derived_name = derived_json_schema["name"] + tool.json_schema = derived_json_schema + tool.name = derived_name + + tool = client.server.tool_manager.create_or_update_tool(tool, actor=client.user) + + # Yield the created tool + yield tool + + +@retry_until_success(max_attempts=3, sleep_time_seconds=2) +def test_send_message_to_agent(client, agent_obj, other_agent_obj): + secret_word = "banana" + + # Encourage the agent to send a message to the other agent_obj with the secret string + client.send_message( + agent_id=agent_obj.agent_state.id, + role="user", + message=f"Use your tool to send a message to another agent with id {other_agent_obj.agent_state.id} to share the secret word: {secret_word}!", + ) + + # Conversation search the other agent + messages = client.get_messages(other_agent_obj.agent_state.id) + # Check for the presence of system message + for m in reversed(messages): + print(f"\n\n {other_agent_obj.agent_state.id} -> {m.model_dump_json(indent=4)}") + if isinstance(m, SystemMessage): + assert secret_word in m.content + break + + # Search the sender agent for the response from another agent + in_context_messages = agent_obj.agent_manager.get_in_context_messages(agent_id=agent_obj.agent_state.id, actor=agent_obj.user) + found = False + target_snippet = f"{other_agent_obj.agent_state.id} said:" + + for m in in_context_messages: + if target_snippet in m.text: + found = True + break + + print(f"In context messages of the sender agent (without system):\n\n{"\n".join([m.text for m in in_context_messages[1:]])}") + if not found: + raise Exception(f"Was not able to find an instance of the target snippet: {target_snippet}") + + # Test that the agent can still receive messages fine + response = client.send_message(agent_id=agent_obj.agent_state.id, role="user", message="So what did the other agent say?") + print(response.messages) + + +@retry_until_success(max_attempts=3, sleep_time_seconds=2) +def test_send_message_to_agents_with_tags_simple(client): + worker_tags = ["worker", "user-456"] + + # Clean up first from possibly failed tests + prev_worker_agents = client.server.agent_manager.list_agents(client.user, tags=worker_tags, match_all_tags=True) + for agent in prev_worker_agents: + client.delete_agent(agent.id) + + secret_word = "banana" + + # Create "manager" agent + send_message_to_agents_matching_all_tags_tool_id = client.get_tool_id(name="send_message_to_agents_matching_all_tags") + manager_agent_state = client.create_agent(tool_ids=[send_message_to_agents_matching_all_tags_tool_id]) + manager_agent = client.server.load_agent(agent_id=manager_agent_state.id, actor=client.user) + + # Create 3 non-matching worker agents (These should NOT get the message) + worker_agents = [] + worker_tags = ["worker", "user-123"] + for _ in range(3): + worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags) + worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) + worker_agents.append(worker_agent) + + # Create 3 worker agents that should get the message + worker_agents = [] + worker_tags = ["worker", "user-456"] + for _ in range(3): + worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags) + worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) + worker_agents.append(worker_agent) + + # Encourage the manager to send a message to the other agent_obj with the secret string + response = client.send_message( + agent_id=manager_agent.agent_state.id, + role="user", + message=f"Send a message to all agents with tags {worker_tags} informing them of the secret word: {secret_word}!", + ) + + for m in response.messages: + if isinstance(m, ToolReturnMessage): + tool_response = eval(json.loads(m.tool_return)["message"]) + print(f"\n\nManager agent tool response: \n{tool_response}\n\n") + assert len(tool_response) == len(worker_agents) + + # We can break after this, the ToolReturnMessage after is not related + break + + # Conversation search the worker agents + for agent in worker_agents: + messages = client.get_messages(agent.agent_state.id) + # Check for the presence of system message + for m in reversed(messages): + print(f"\n\n {agent.agent_state.id} -> {m.model_dump_json(indent=4)}") + if isinstance(m, SystemMessage): + assert secret_word in m.content + break + + # Test that the agent can still receive messages fine + response = client.send_message(agent_id=manager_agent.agent_state.id, role="user", message="So what did the other agents say?") + print("Manager agent followup message: \n\n" + "\n".join([str(m) for m in response.messages])) + + # Clean up agents + client.delete_agent(manager_agent_state.id) + for agent in worker_agents: + client.delete_agent(agent.agent_state.id) + + +@retry_until_success(max_attempts=3, sleep_time_seconds=2) +def test_send_message_to_agents_with_tags_complex_tool_use(client, roll_dice_tool): + worker_tags = ["dice-rollers"] + + # Clean up first from possibly failed tests + prev_worker_agents = client.server.agent_manager.list_agents(client.user, tags=worker_tags, match_all_tags=True) + for agent in prev_worker_agents: + client.delete_agent(agent.id) + + # Create "manager" agent + send_message_to_agents_matching_all_tags_tool_id = client.get_tool_id(name="send_message_to_agents_matching_all_tags") + manager_agent_state = client.create_agent(tool_ids=[send_message_to_agents_matching_all_tags_tool_id]) + manager_agent = client.server.load_agent(agent_id=manager_agent_state.id, actor=client.user) + + # Create 3 worker agents + worker_agents = [] + worker_tags = ["dice-rollers"] + for _ in range(2): + worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags, tool_ids=[roll_dice_tool.id]) + worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) + worker_agents.append(worker_agent) + + # Encourage the manager to send a message to the other agent_obj with the secret string + broadcast_message = f"Send a message to all agents with tags {worker_tags} asking them to roll a dice for you!" + response = client.send_message( + agent_id=manager_agent.agent_state.id, + role="user", + message=broadcast_message, + ) + + for m in response.messages: + if isinstance(m, ToolReturnMessage): + tool_response = eval(json.loads(m.tool_return)["message"]) + print(f"\n\nManager agent tool response: \n{tool_response}\n\n") + assert len(tool_response) == len(worker_agents) + + # We can break after this, the ToolReturnMessage after is not related + break + + # Test that the agent can still receive messages fine + response = client.send_message(agent_id=manager_agent.agent_state.id, role="user", message="So what did the other agents say?") + print("Manager agent followup message: \n\n" + "\n".join([str(m) for m in response.messages])) + + # Clean up agents + client.delete_agent(manager_agent_state.id) + for agent in worker_agents: + client.delete_agent(agent.agent_state.id) + + +@retry_until_success(max_attempts=3, sleep_time_seconds=2) +def test_send_message_to_sub_agents_auto_clear_message_buffer(client): + # Create "manager" agent + send_message_to_agents_matching_all_tags_tool_id = client.get_tool_id(name="send_message_to_agents_matching_all_tags") + manager_agent_state = client.create_agent(name="manager", tool_ids=[send_message_to_agents_matching_all_tags_tool_id]) + manager_agent = client.server.load_agent(agent_id=manager_agent_state.id, actor=client.user) + + # Create 2 worker agents + worker_agents = [] + worker_tags = ["banana-boys"] + for i in range(2): + worker_agent_state = client.create_agent( + name=f"worker_{i}", include_multi_agent_tools=False, tags=worker_tags, message_buffer_autoclear=True + ) + worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) + worker_agents.append(worker_agent) + + # Encourage the manager to send a message to the other agent_obj with the secret string + broadcast_message = f"Using your tool named `send_message_to_agents_matching_all_tags`, instruct all agents with tags {worker_tags} to `core_memory_append` the topic of the day: bananas!" + client.send_message( + agent_id=manager_agent.agent_state.id, + role="user", + message=broadcast_message, + ) + + for worker_agent in worker_agents: + worker_agent_state = client.server.load_agent(agent_id=worker_agent.agent_state.id, actor=client.user).agent_state + # assert there's only one message in the message_ids + assert len(worker_agent_state.message_ids) == 1 + # check that banana made it in + assert "banana" in worker_agent_state.memory.compile().lower() + + +@retry_until_success(max_attempts=3, sleep_time_seconds=2) +def test_agents_async_simple(client): + """ + Test two agents with multi-agent tools sending messages back and forth to count to 5. + The chain is started by prompting one of the agents. + """ + # Cleanup from potentially failed previous runs + existing_agents = client.server.agent_manager.list_agents(client.user) + for agent in existing_agents: + client.delete_agent(agent.id) + + # Create two agents with multi-agent tools + send_message_to_agent_async_tool_id = client.get_tool_id(name="send_message_to_agent_async") + memory_a = ChatMemory( + human="Chad - I'm interested in hearing poem.", + persona="You are an AI agent that can communicate with your agent buddy using `send_message_to_agent_async`, who has some great poem ideas (so I've heard).", + ) + charles_state = client.create_agent(name="charles", memory=memory_a, tool_ids=[send_message_to_agent_async_tool_id]) + charles = client.server.load_agent(agent_id=charles_state.id, actor=client.user) + + memory_b = ChatMemory( + human="No human - you are to only communicate with the other AI agent.", + persona="You are an AI agent that can communicate with your agent buddy using `send_message_to_agent_async`, who is interested in great poem ideas.", + ) + sarah_state = client.create_agent(name="sarah", memory=memory_b, tool_ids=[send_message_to_agent_async_tool_id]) + + # Start the count chain with Agent1 + initial_prompt = f"I want you to talk to the other agent with ID {sarah_state.id} using `send_message_to_agent_async`. Specifically, I want you to ask him for a poem idea, and then craft a poem for me." + client.send_message( + agent_id=charles.agent_state.id, + role="user", + message=initial_prompt, + ) + + found_in_charles = wait_for_incoming_message( + client=client, + agent_id=charles_state.id, + substring="[Incoming message from agent with ID", + max_wait_seconds=10, + sleep_interval=0.5, + ) + assert found_in_charles, "Charles never received the system message from Sarah (timed out)." + + found_in_sarah = wait_for_incoming_message( + client=client, + agent_id=sarah_state.id, + substring="[Incoming message from agent with ID", + max_wait_seconds=10, + sleep_interval=0.5, + ) + assert found_in_sarah, "Sarah never received the system message from Charles (timed out)." diff --git a/tests/test_base_functions.py b/tests/test_base_functions.py index b5ce510421..037eda2f43 100644 --- a/tests/test_base_functions.py +++ b/tests/test_base_functions.py @@ -1,17 +1,9 @@ -import json - import pytest import letta.functions.function_sets.base as base_functions from letta import LocalClient, create_client -from letta.functions.functions import derive_openai_json_schema, parse_source_code from letta.schemas.embedding_config import EmbeddingConfig -from letta.schemas.letta_message import SystemMessage, ToolReturnMessage from letta.schemas.llm_config import LLMConfig -from letta.schemas.memory import ChatMemory -from letta.schemas.tool import Tool -from tests.helpers.utils import retry_until_success -from tests.utils import wait_for_incoming_message @pytest.fixture(scope="function") @@ -35,47 +27,6 @@ def agent_obj(client: LocalClient): # client.delete_agent(agent_obj.agent_state.id) -@pytest.fixture(scope="function") -def other_agent_obj(client: LocalClient): - """Create another test agent that we can call functions on""" - agent_state = client.create_agent(include_multi_agent_tools=False) - - other_agent_obj = client.server.load_agent(agent_id=agent_state.id, actor=client.user) - yield other_agent_obj - - client.delete_agent(other_agent_obj.agent_state.id) - - -@pytest.fixture -def roll_dice_tool(client): - def roll_dice(): - """ - Rolls a 6 sided die. - - Returns: - str: The roll result. - """ - return "Rolled a 5!" - - # Set up tool details - source_code = parse_source_code(roll_dice) - source_type = "python" - description = "test_description" - tags = ["test"] - - tool = Tool(description=description, tags=tags, source_code=source_code, source_type=source_type) - derived_json_schema = derive_openai_json_schema(source_code=tool.source_code, name=tool.name) - - derived_name = derived_json_schema["name"] - tool.json_schema = derived_json_schema - tool.name = derived_name - - tool = client.server.tool_manager.create_or_update_tool(tool, actor=client.user) - - # Yield the created tool - yield tool - - def query_in_search_results(search_results, query): for result in search_results: if query.lower() in result["content"].lower(): @@ -147,214 +98,3 @@ def test_recall(client, agent_obj): # Conversation search result = base_functions.conversation_search(agent_obj, "banana") assert keyword in result - - -# This test is nondeterministic, so we retry until we get the perfect behavior from the LLM -@retry_until_success(max_attempts=2, sleep_time_seconds=2) -def test_send_message_to_agent(client, agent_obj, other_agent_obj): - secret_word = "banana" - - # Encourage the agent to send a message to the other agent_obj with the secret string - client.send_message( - agent_id=agent_obj.agent_state.id, - role="user", - message=f"Use your tool to send a message to another agent with id {other_agent_obj.agent_state.id} to share the secret word: {secret_word}!", - ) - - # Conversation search the other agent - messages = client.get_messages(other_agent_obj.agent_state.id) - # Check for the presence of system message - for m in reversed(messages): - print(f"\n\n {other_agent_obj.agent_state.id} -> {m.model_dump_json(indent=4)}") - if isinstance(m, SystemMessage): - assert secret_word in m.content - break - - # Search the sender agent for the response from another agent - in_context_messages = agent_obj.agent_manager.get_in_context_messages(agent_id=agent_obj.agent_state.id, actor=agent_obj.user) - found = False - target_snippet = f"{other_agent_obj.agent_state.id} said:" - - for m in in_context_messages: - if target_snippet in m.text: - found = True - break - - print(f"In context messages of the sender agent (without system):\n\n{"\n".join([m.text for m in in_context_messages[1:]])}") - if not found: - raise Exception(f"Was not able to find an instance of the target snippet: {target_snippet}") - - # Test that the agent can still receive messages fine - response = client.send_message(agent_id=agent_obj.agent_state.id, role="user", message="So what did the other agent say?") - print(response.messages) - - -@retry_until_success(max_attempts=2, sleep_time_seconds=2) -def test_send_message_to_agents_with_tags_simple(client): - worker_tags = ["worker", "user-456"] - - # Clean up first from possibly failed tests - prev_worker_agents = client.server.agent_manager.list_agents(client.user, tags=worker_tags, match_all_tags=True) - for agent in prev_worker_agents: - client.delete_agent(agent.id) - - secret_word = "banana" - - # Create "manager" agent - send_message_to_agents_matching_all_tags_tool_id = client.get_tool_id(name="send_message_to_agents_matching_all_tags") - manager_agent_state = client.create_agent(tool_ids=[send_message_to_agents_matching_all_tags_tool_id]) - manager_agent = client.server.load_agent(agent_id=manager_agent_state.id, actor=client.user) - - # Create 3 non-matching worker agents (These should NOT get the message) - worker_agents = [] - worker_tags = ["worker", "user-123"] - for _ in range(3): - worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags) - worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) - worker_agents.append(worker_agent) - - # Create 3 worker agents that should get the message - worker_agents = [] - worker_tags = ["worker", "user-456"] - for _ in range(3): - worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags) - worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) - worker_agents.append(worker_agent) - - # Encourage the manager to send a message to the other agent_obj with the secret string - response = client.send_message( - agent_id=manager_agent.agent_state.id, - role="user", - message=f"Send a message to all agents with tags {worker_tags} informing them of the secret word: {secret_word}!", - ) - - for m in response.messages: - if isinstance(m, ToolReturnMessage): - tool_response = eval(json.loads(m.tool_return)["message"]) - print(f"\n\nManager agent tool response: \n{tool_response}\n\n") - assert len(tool_response) == len(worker_agents) - - # We can break after this, the ToolReturnMessage after is not related - break - - # Conversation search the worker agents - for agent in worker_agents: - messages = client.get_messages(agent.agent_state.id) - # Check for the presence of system message - for m in reversed(messages): - print(f"\n\n {agent.agent_state.id} -> {m.model_dump_json(indent=4)}") - if isinstance(m, SystemMessage): - assert secret_word in m.content - break - - # Test that the agent can still receive messages fine - response = client.send_message(agent_id=manager_agent.agent_state.id, role="user", message="So what did the other agents say?") - print("Manager agent followup message: \n\n" + "\n".join([str(m) for m in response.messages])) - - # Clean up agents - client.delete_agent(manager_agent_state.id) - for agent in worker_agents: - client.delete_agent(agent.agent_state.id) - - -# This test is nondeterministic, so we retry until we get the perfect behavior from the LLM -@retry_until_success(max_attempts=2, sleep_time_seconds=2) -def test_send_message_to_agents_with_tags_complex_tool_use(client, roll_dice_tool): - worker_tags = ["dice-rollers"] - - # Clean up first from possibly failed tests - prev_worker_agents = client.server.agent_manager.list_agents(client.user, tags=worker_tags, match_all_tags=True) - for agent in prev_worker_agents: - client.delete_agent(agent.id) - - # Create "manager" agent - send_message_to_agents_matching_all_tags_tool_id = client.get_tool_id(name="send_message_to_agents_matching_all_tags") - manager_agent_state = client.create_agent(tool_ids=[send_message_to_agents_matching_all_tags_tool_id]) - manager_agent = client.server.load_agent(agent_id=manager_agent_state.id, actor=client.user) - - # Create 3 worker agents - worker_agents = [] - worker_tags = ["dice-rollers"] - for _ in range(2): - worker_agent_state = client.create_agent(include_multi_agent_tools=False, tags=worker_tags, tool_ids=[roll_dice_tool.id]) - worker_agent = client.server.load_agent(agent_id=worker_agent_state.id, actor=client.user) - worker_agents.append(worker_agent) - - # Encourage the manager to send a message to the other agent_obj with the secret string - broadcast_message = f"Send a message to all agents with tags {worker_tags} asking them to roll a dice for you!" - response = client.send_message( - agent_id=manager_agent.agent_state.id, - role="user", - message=broadcast_message, - ) - - for m in response.messages: - if isinstance(m, ToolReturnMessage): - tool_response = eval(json.loads(m.tool_return)["message"]) - print(f"\n\nManager agent tool response: \n{tool_response}\n\n") - assert len(tool_response) == len(worker_agents) - - # We can break after this, the ToolReturnMessage after is not related - break - - # Test that the agent can still receive messages fine - response = client.send_message(agent_id=manager_agent.agent_state.id, role="user", message="So what did the other agents say?") - print("Manager agent followup message: \n\n" + "\n".join([str(m) for m in response.messages])) - - # Clean up agents - client.delete_agent(manager_agent_state.id) - for agent in worker_agents: - client.delete_agent(agent.agent_state.id) - - -@retry_until_success(max_attempts=5, sleep_time_seconds=2) -def test_agents_async_simple(client): - """ - Test two agents with multi-agent tools sending messages back and forth to count to 5. - The chain is started by prompting one of the agents. - """ - # Cleanup from potentially failed previous runs - existing_agents = client.server.agent_manager.list_agents(client.user) - for agent in existing_agents: - client.delete_agent(agent.id) - - # Create two agents with multi-agent tools - send_message_to_agent_async_tool_id = client.get_tool_id(name="send_message_to_agent_async") - memory_a = ChatMemory( - human="Chad - I'm interested in hearing poem.", - persona="You are an AI agent that can communicate with your agent buddy using `send_message_to_agent_async`, who has some great poem ideas (so I've heard).", - ) - charles_state = client.create_agent(name="charles", memory=memory_a, tool_ids=[send_message_to_agent_async_tool_id]) - charles = client.server.load_agent(agent_id=charles_state.id, actor=client.user) - - memory_b = ChatMemory( - human="No human - you are to only communicate with the other AI agent.", - persona="You are an AI agent that can communicate with your agent buddy using `send_message_to_agent_async`, who is interested in great poem ideas.", - ) - sarah_state = client.create_agent(name="sarah", memory=memory_b, tool_ids=[send_message_to_agent_async_tool_id]) - - # Start the count chain with Agent1 - initial_prompt = f"I want you to talk to the other agent with ID {sarah_state.id} using `send_message_to_agent_async`. Specifically, I want you to ask him for a poem idea, and then craft a poem for me." - client.send_message( - agent_id=charles.agent_state.id, - role="user", - message=initial_prompt, - ) - - found_in_charles = wait_for_incoming_message( - client=client, - agent_id=charles_state.id, - substring="[Incoming message from agent with ID", - max_wait_seconds=10, - sleep_interval=0.5, - ) - assert found_in_charles, "Charles never received the system message from Sarah (timed out)." - - found_in_sarah = wait_for_incoming_message( - client=client, - agent_id=sarah_state.id, - substring="[Incoming message from agent with ID", - max_wait_seconds=10, - sleep_interval=0.5, - ) - assert found_in_sarah, "Sarah never received the system message from Charles (timed out)." From 8955dd939b3c2cf26ebbbec2606eccb223aa6c92 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 12 Feb 2025 18:06:26 -0800 Subject: [PATCH 7/8] feat: google vertex integration (#980) --- letta/embeddings.py | 21 ++ letta/llm_api/google_vertex.py | 330 ++++++++++++++++++ letta/llm_api/llm_api_tools.py | 26 ++ letta/schemas/embedding_config.py | 1 + letta/schemas/llm_config.py | 1 + letta/schemas/providers.py | 45 ++- letta/server/server.py | 8 + letta/settings.py | 5 + poetry.lock | 297 ++++++++++------ pyproject.toml | 3 +- .../llm_model_configs/gemini-vertex.json | 7 + tests/test_model_letta_performance.py | 12 + tests/test_providers.py | 11 + 13 files changed, 659 insertions(+), 108 deletions(-) create mode 100644 letta/llm_api/google_vertex.py create mode 100644 tests/configs/llm_model_configs/gemini-vertex.json diff --git a/letta/embeddings.py b/letta/embeddings.py index 6541cea360..e8d1f54d91 100644 --- a/letta/embeddings.py +++ b/letta/embeddings.py @@ -188,6 +188,19 @@ def get_text_embedding(self, text: str): return response_json["embedding"]["values"] +class GoogleVertexEmbeddings: + + def __init__(self, model: str, project_id: str, region: str): + from google import genai + + self.client = genai.Client(vertexai=True, project=project_id, location=region, http_options={"api_version": "v1"}) + self.model = model + + def get_text_embedding(self, text: str): + response = self.client.generate_embeddings(content=text, model=self.model) + return response.embeddings[0].embedding + + def query_embedding(embedding_model, query_text: str): """Generate padded embedding for querying database""" query_vec = embedding_model.get_text_embedding(query_text) @@ -267,5 +280,13 @@ def embedding_model(config: EmbeddingConfig, user_id: Optional[uuid.UUID] = None ) return model + elif endpoint_type == "google_vertex": + model = GoogleVertexEmbeddings( + model=config.embedding_model, + api_key=model_settings.gemini_api_key, + base_url=model_settings.gemini_base_url, + ) + return model + else: raise ValueError(f"Unknown endpoint type {endpoint_type}") diff --git a/letta/llm_api/google_vertex.py b/letta/llm_api/google_vertex.py new file mode 100644 index 0000000000..7b551af258 --- /dev/null +++ b/letta/llm_api/google_vertex.py @@ -0,0 +1,330 @@ +import uuid +from typing import List, Optional, Tuple + +import requests + +from letta.constants import NON_USER_MSG_PREFIX +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 + + +def add_dummy_model_messages(messages: List[dict]) -> List[dict]: + """Google AI API requires all function call returns are immediately followed by a 'model' role message. + + In Letta, the 'model' will often call a function (e.g. send_message) that itself yields to the user, + so there is no natural follow-up 'model' role message. + + To satisfy the Google AI API restrictions, we can add a dummy 'yield' message + with role == 'model' that is placed in-betweeen and function output + (role == 'tool') and user message (role == 'user'). + """ + dummy_yield_message = {"role": "model", "parts": [{"text": f"{NON_USER_MSG_PREFIX}Function call returned, waiting for user response."}]} + messages_with_padding = [] + for i, message in enumerate(messages): + messages_with_padding.append(message) + # Check if the current message role is 'tool' and the next message role is 'user' + if message["role"] in ["tool", "function"] and (i + 1 < len(messages) and messages[i + 1]["role"] == "user"): + messages_with_padding.append(dummy_yield_message) + + return messages_with_padding + + +# TODO use pydantic model as input +def to_google_ai(openai_message_dict: dict) -> dict: + + # TODO supports "parts" as part of multimodal support + assert not isinstance(openai_message_dict["content"], list), "Multi-part content is message not yet supported" + if openai_message_dict["role"] == "user": + google_ai_message_dict = { + "role": "user", + "parts": [{"text": openai_message_dict["content"]}], + } + elif openai_message_dict["role"] == "assistant": + google_ai_message_dict = { + "role": "model", # NOTE: diff + "parts": [{"text": openai_message_dict["content"]}], + } + elif openai_message_dict["role"] == "tool": + google_ai_message_dict = { + "role": "function", # NOTE: diff + "parts": [{"text": openai_message_dict["content"]}], + } + else: + raise ValueError(f"Unsupported conversion (OpenAI -> Google AI) from role {openai_message_dict['role']}") + + +# TODO convert return type to pydantic +def convert_tools_to_google_ai_format(tools: List[Tool], inner_thoughts_in_kwargs: Optional[bool] = True) -> List[dict]: + """ + OpenAI style: + "tools": [{ + "type": "function", + "function": { + "name": "find_movies", + "description": "find ....", + "parameters": { + "type": "object", + "properties": { + PARAM: { + "type": PARAM_TYPE, # eg "string" + "description": PARAM_DESCRIPTION, + }, + ... + }, + "required": List[str], + } + } + } + ] + + Google AI style: + "tools": [{ + "functionDeclarations": [{ + "name": "find_movies", + "description": "find movie titles currently playing in theaters based on any description, genre, title words, etc.", + "parameters": { + "type": "OBJECT", + "properties": { + "location": { + "type": "STRING", + "description": "The city and state, e.g. San Francisco, CA or a zip code e.g. 95616" + }, + "description": { + "type": "STRING", + "description": "Any kind of description including category or genre, title words, attributes, etc." + } + }, + "required": ["description"] + } + }, { + "name": "find_theaters", + ... + """ + function_list = [ + dict( + name=t.function.name, + description=t.function.description, + parameters=t.function.parameters, # TODO need to unpack + ) + for t in tools + ] + + # Correct casing + add inner thoughts if needed + for func in function_list: + func["parameters"]["type"] = "OBJECT" + for param_name, param_fields in func["parameters"]["properties"].items(): + param_fields["type"] = param_fields["type"].upper() + # Add inner thoughts + if inner_thoughts_in_kwargs: + from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION + + func["parameters"]["properties"][INNER_THOUGHTS_KWARG] = { + "type": "STRING", + "description": INNER_THOUGHTS_KWARG_DESCRIPTION, + } + func["parameters"]["required"].append(INNER_THOUGHTS_KWARG) + + return [{"functionDeclarations": function_list}] + + +def convert_google_ai_response_to_chatcompletion( + response, + model: str, # Required since not returned + input_messages: Optional[List[dict]] = None, # Required if the API doesn't return UsageMetadata + pull_inner_thoughts_from_args: Optional[bool] = True, +) -> ChatCompletionResponse: + """Google AI API response format is not the same as ChatCompletion, requires unpacking + + Example: + { + "candidates": [ + { + "content": { + "parts": [ + { + "text": " OK. Barbie is showing in two theaters in Mountain View, CA: AMC Mountain View 16 and Regal Edwards 14." + } + ] + } + } + ], + "usageMetadata": { + "promptTokenCount": 9, + "candidatesTokenCount": 27, + "totalTokenCount": 36 + } + } + """ + try: + choices = [] + index = 0 + for candidate in response.candidates: + content = candidate.content + + role = content.role + assert role == "model", f"Unknown role in response: {role}" + + parts = content.parts + # TODO support parts / multimodal + # TODO support parallel tool calling natively + # TODO Alternative here is to throw away everything else except for the first part + for response_message in parts: + # Convert the actual message style to OpenAI style + if response_message.function_call: + function_call = response_message.function_call + function_name = function_call.name + function_args = function_call.args + assert isinstance(function_args, dict), function_args + + # NOTE: this also involves stripping the inner monologue out of the function + if pull_inner_thoughts_from_args: + from letta.local_llm.constants import INNER_THOUGHTS_KWARG + + assert INNER_THOUGHTS_KWARG in function_args, f"Couldn't find inner thoughts in function args:\n{function_call}" + inner_thoughts = function_args.pop(INNER_THOUGHTS_KWARG) + assert inner_thoughts is not None, f"Expected non-null inner thoughts function arg:\n{function_call}" + else: + inner_thoughts = None + + # Google AI API doesn't generate tool call IDs + openai_response_message = Message( + role="assistant", # NOTE: "model" -> "assistant" + content=inner_thoughts, + tool_calls=[ + ToolCall( + id=get_tool_call_id(), + type="function", + function=FunctionCall( + name=function_name, + arguments=clean_json_string_extra_backslash(json_dumps(function_args)), + ), + ) + ], + ) + + else: + + # Inner thoughts are the content by default + inner_thoughts = response_message.text + + # Google AI API doesn't generate tool call IDs + openai_response_message = Message( + role="assistant", # NOTE: "model" -> "assistant" + content=inner_thoughts, + ) + + # Google AI API uses different finish reason strings than OpenAI + # OpenAI: 'stop', 'length', 'function_call', 'content_filter', null + # see: https://platform.openai.com/docs/guides/text-generation/chat-completions-api + # Google AI API: FINISH_REASON_UNSPECIFIED, STOP, MAX_TOKENS, SAFETY, RECITATION, OTHER + # see: https://ai.google.dev/api/python/google/ai/generativelanguage/Candidate/FinishReason + finish_reason = candidate.finish_reason.value + if finish_reason == "STOP": + openai_finish_reason = ( + "function_call" + if openai_response_message.tool_calls is not None and len(openai_response_message.tool_calls) > 0 + else "stop" + ) + elif finish_reason == "MAX_TOKENS": + openai_finish_reason = "length" + elif finish_reason == "SAFETY": + openai_finish_reason = "content_filter" + elif finish_reason == "RECITATION": + openai_finish_reason = "content_filter" + else: + raise ValueError(f"Unrecognized finish reason in Google AI response: {finish_reason}") + + choices.append( + Choice( + finish_reason=openai_finish_reason, + index=index, + message=openai_response_message, + ) + ) + index += 1 + + # if len(choices) > 1: + # raise UserWarning(f"Unexpected number of candidates in response (expected 1, got {len(choices)})") + + # NOTE: some of the Google AI APIs show UsageMetadata in the response, but it seems to not exist? + # "usageMetadata": { + # "promptTokenCount": 9, + # "candidatesTokenCount": 27, + # "totalTokenCount": 36 + # } + if response.usage_metadata: + usage = UsageStatistics( + prompt_tokens=response.usage_metadata.prompt_token_count, + completion_tokens=response.usage_metadata.candidates_token_count, + total_tokens=response.usage_metadata.total_token_count, + ) + else: + # Count it ourselves + assert input_messages is not None, f"Didn't get UsageMetadata from the API response, so input_messages is required" + prompt_tokens = count_tokens(json_dumps(input_messages)) # NOTE: this is a very rough approximation + completion_tokens = count_tokens(json_dumps(openai_response_message.model_dump())) # NOTE: this is also approximate + total_tokens = prompt_tokens + completion_tokens + usage = UsageStatistics( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + ) + + response_id = str(uuid.uuid4()) + return ChatCompletionResponse( + id=response_id, + choices=choices, + model=model, # NOTE: Google API doesn't pass back model in the response + created=get_utc_time(), + usage=usage, + ) + except KeyError as e: + raise e + + +# TODO convert 'data' type to pydantic +def google_vertex_chat_completions_request( + model: str, + project_id: str, + region: str, + contents: List[dict], + config: dict, + add_postfunc_model_messages: bool = True, + # NOTE: Google AI API doesn't support mixing parts 'text' and 'function', + # so there's no clean way to put inner thoughts in the same message as a function call + inner_thoughts_in_kwargs: bool = True, +) -> ChatCompletionResponse: + """https://ai.google.dev/docs/function_calling + + From https://ai.google.dev/api/rest#service-endpoint: + "A service endpoint is a base URL that specifies the network address of an API service. + One service might have multiple service endpoints. + This service has the following service endpoint and all URIs below are relative to this service endpoint: + https://xxx.googleapis.com + """ + + from google import genai + + client = genai.Client(vertexai=True, project=project_id, location=region, http_options={"api_version": "v1"}) + # add dummy model messages to the end of the input + if add_postfunc_model_messages: + contents = add_dummy_model_messages(contents) + + # make request to client + response = client.models.generate_content(model=model, contents=contents, config=config) + print(response) + + # convert back response + try: + return convert_google_ai_response_to_chatcompletion( + response=response, + model=model, + input_messages=contents, + pull_inner_thoughts_from_args=inner_thoughts_in_kwargs, + ) + except Exception as conversion_error: + print(f"Error during response conversion: {conversion_error}") + raise conversion_error diff --git a/letta/llm_api/llm_api_tools.py b/letta/llm_api/llm_api_tools.py index 77ba4839d6..65bdc1f12a 100644 --- a/letta/llm_api/llm_api_tools.py +++ b/letta/llm_api/llm_api_tools.py @@ -252,6 +252,32 @@ def create( inner_thoughts_in_kwargs=llm_config.put_inner_thoughts_in_kwargs, ) + elif llm_config.model_endpoint_type == "google_vertex": + from letta.llm_api.google_vertex import google_vertex_chat_completions_request + + if stream: + raise NotImplementedError(f"Streaming not yet implemented for {llm_config.model_endpoint_type}") + if not use_tool_naming: + raise NotImplementedError("Only tool calling supported on Google Vertex AI API requests") + + if functions is not None: + tools = [{"type": "function", "function": f} for f in functions] + tools = [Tool(**t) for t in tools] + tools = convert_tools_to_google_ai_format(tools, inner_thoughts_in_kwargs=llm_config.put_inner_thoughts_in_kwargs) + else: + tools = None + + config = {"tools": tools, "temperature": llm_config.temperature, "max_output_tokens": llm_config.max_tokens} + + return google_vertex_chat_completions_request( + model=llm_config.model, + project_id=model_settings.google_cloud_project, + region=model_settings.google_cloud_location, + contents=[m.to_google_ai_dict() for m in messages], + config=config, + inner_thoughts_in_kwargs=llm_config.put_inner_thoughts_in_kwargs, + ) + elif llm_config.model_endpoint_type == "anthropic": if not use_tool_naming: raise NotImplementedError("Only tool calling supported on Anthropic API requests") diff --git a/letta/schemas/embedding_config.py b/letta/schemas/embedding_config.py index c0a569a7ba..25162d0b89 100644 --- a/letta/schemas/embedding_config.py +++ b/letta/schemas/embedding_config.py @@ -26,6 +26,7 @@ class EmbeddingConfig(BaseModel): "bedrock", "cohere", "google_ai", + "google_vertex", "azure", "groq", "ollama", diff --git a/letta/schemas/llm_config.py b/letta/schemas/llm_config.py index e3877389a8..8e44b25e82 100644 --- a/letta/schemas/llm_config.py +++ b/letta/schemas/llm_config.py @@ -25,6 +25,7 @@ class LLMConfig(BaseModel): "anthropic", "cohere", "google_ai", + "google_vertex", "azure", "groq", "ollama", diff --git a/letta/schemas/providers.py b/letta/schemas/providers.py index e96787598a..621958cc7f 100644 --- a/letta/schemas/providers.py +++ b/letta/schemas/providers.py @@ -327,7 +327,7 @@ def list_embedding_models(self) -> List[EmbeddingConfig]: embedding_endpoint_type="openai", embedding_endpoint=self.base_url, embedding_dim=context_window_size, - embedding_chunk_size=300, + embedding_chunk_size=300, # NOTE: max is 2048 handle=self.get_handle(model_name), ), ) @@ -737,6 +737,45 @@ def get_model_context_window(self, model_name: str) -> Optional[int]: return google_ai_get_model_context_window(self.base_url, self.api_key, model_name) +class GoogleVertexProvider(Provider): + name: str = "google_vertex" + google_cloud_project: str = Field(..., description="GCP project ID for the Google Vertex API.") + google_cloud_location: str = Field(..., description="GCP region for the Google Vertex API.") + + def list_llm_models(self) -> List[LLMConfig]: + from letta.llm_api.google_constants import GOOGLE_MODEL_TO_CONTEXT_LENGTH + + configs = [] + for model, context_length in GOOGLE_MODEL_TO_CONTEXT_LENGTH.items(): + configs.append( + LLMConfig( + model=model, + model_endpoint_type="google_vertex", + model_endpoint=f"https://{self.google_cloud_location}-aiplatform.googleapis.com/v1/projects/{self.google_cloud_project}/locations/{self.google_cloud_location}", + context_window=context_length, + handle=self.get_handle(model), + ) + ) + return configs + + def list_embedding_models(self) -> List[EmbeddingConfig]: + from letta.llm_api.google_constants import GOOGLE_EMBEDING_MODEL_TO_DIM + + configs = [] + for model, dim in GOOGLE_EMBEDING_MODEL_TO_DIM.items(): + configs.append( + EmbeddingConfig( + embedding_model=model, + embedding_endpoint_type="google_vertex", + embedding_endpoint=f"https://{self.google_cloud_location}-aiplatform.googleapis.com/v1/projects/{self.google_cloud_project}/locations/{self.google_cloud_location}", + embedding_dim=dim, + embedding_chunk_size=300, # NOTE: max is 2048 + handle=self.get_handle(model, is_embedding=True), + ) + ) + return configs + + class AzureProvider(Provider): name: str = "azure" latest_api_version: str = "2024-09-01-preview" # https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation @@ -792,8 +831,8 @@ def list_embedding_models(self) -> List[EmbeddingConfig]: embedding_endpoint=model_endpoint, embedding_dim=768, embedding_chunk_size=300, # NOTE: max is 2048 - handle=self.get_handle(model_name, is_embedding=True), - ) + handle=self.get_handle(model_name), + ), ) return configs diff --git a/letta/server/server.py b/letta/server/server.py index 75ac63bbcc..5c32182a82 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -47,6 +47,7 @@ AnthropicProvider, AzureProvider, GoogleAIProvider, + GoogleVertexProvider, GroqProvider, LettaProvider, LMStudioOpenAIProvider, @@ -352,6 +353,13 @@ def __init__( api_key=model_settings.gemini_api_key, ) ) + if model_settings.google_cloud_location and model_settings.google_cloud_project: + self._enabled_providers.append( + GoogleVertexProvider( + google_cloud_project=model_settings.google_cloud_project, + google_cloud_location=model_settings.google_cloud_location, + ) + ) if model_settings.azure_api_key and model_settings.azure_base_url: assert model_settings.azure_api_version, "AZURE_API_VERSION is required" self._enabled_providers.append( diff --git a/letta/settings.py b/letta/settings.py index 4fda300c2e..4e9f0d0b93 100644 --- a/letta/settings.py +++ b/letta/settings.py @@ -86,6 +86,11 @@ class ModelSettings(BaseSettings): # google ai gemini_api_key: Optional[str] = None gemini_base_url: str = "https://generativelanguage.googleapis.com/" + + # google vertex + google_cloud_project: Optional[str] = None + google_cloud_location: Optional[str] = None + # together together_api_key: Optional[str] = None diff --git a/poetry.lock b/poetry.lock index a03ffeb1a2..89003d564a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -539,6 +539,17 @@ files = [ {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, ] +[[package]] +name = "cachetools" +version = "5.5.1" +description = "Extensible memoizing collections and decorators" +optional = true +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -1606,6 +1617,47 @@ benchmarks = ["httplib2", "httpx", "requests", "urllib3"] dev = ["dpkt", "pytest", "requests"] examples = ["oauth2"] +[[package]] +name = "google-auth" +version = "2.38.0" +description = "Google Authentication Library" +optional = true +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a"}, + {file = "google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-genai" +version = "1.2.0" +description = "GenAI Python SDK" +optional = true +python-versions = ">=3.9" +files = [ + {file = "google_genai-1.2.0-py3-none-any.whl", hash = "sha256:609d61bee73f1a6ae5b47e9c7dd4b469d50318f050c5ceacf835b0f80f79d2d9"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.0dev" +pydantic = ">=2.0.0,<3.0.0dev" +requests = ">=2.28.1,<3.0.0dev" +typing-extensions = ">=4.11.0,<5.0.0dev" +websockets = ">=13.0,<15.0dev" + [[package]] name = "greenlet" version = "3.1.1" @@ -2481,13 +2533,13 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10" [[package]] name = "langchain-core" -version = "0.3.34" +version = "0.3.35" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "langchain_core-0.3.34-py3-none-any.whl", hash = "sha256:a057ebeddd2158d3be14bde341b25640ddf958b6989bd6e47160396f5a8202ae"}, - {file = "langchain_core-0.3.34.tar.gz", hash = "sha256:26504cf1e8e6c310adad907b890d4e3c147581cfa7434114f6dc1134fe4bc6d3"}, + {file = "langchain_core-0.3.35-py3-none-any.whl", hash = "sha256:81a4097226e180fa6c64e2d2ab38dcacbbc23b64fc109fb15622910fe8951670"}, + {file = "langchain_core-0.3.35.tar.gz", hash = "sha256:328688228ece259da734417d477994a69cf8202dea9ed4271f2d792e3575c6fc"}, ] [package.dependencies] @@ -2576,13 +2628,13 @@ pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4,<14.0.0)"] [[package]] name = "letta-client" -version = "0.1.27" +version = "0.1.29" description = "" optional = false python-versions = "<4.0,>=3.8" files = [ - {file = "letta_client-0.1.27-py3-none-any.whl", hash = "sha256:dbd8ac70993f8b2776bf2203e46e6d637a216a7b85974217534b3cf29479fabf"}, - {file = "letta_client-0.1.27.tar.gz", hash = "sha256:191b29810c02e4b4818542affca41da4a2f2de97d05aac04fdab32b5b52a5da5"}, + {file = "letta_client-0.1.29-py3-none-any.whl", hash = "sha256:72b1b5d69c9277e19fdb0ddeeb572fd0b8d10bd644ec6fd7ddf32c3704052ec6"}, + {file = "letta_client-0.1.29.tar.gz", hash = "sha256:3fa4c9522be20457887142d24f579bc246244d713bd0d92c253785f007446f67"}, ] [package.dependencies] @@ -2610,13 +2662,13 @@ pydantic = ">=1.10" [[package]] name = "llama-cloud-services" -version = "0.6.0" +version = "0.6.1" description = "Tailored SDK clients for LlamaCloud services." optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_cloud_services-0.6.0-py3-none-any.whl", hash = "sha256:b9647e236ba4e13d0b04bf336ed4023e422a2a48d363258924056ef9eb8f688d"}, - {file = "llama_cloud_services-0.6.0.tar.gz", hash = "sha256:9c1ed2849f8ba7374df16bdfda69bed145eb4a425b546048f5e751c48efe293a"}, + {file = "llama_cloud_services-0.6.1-py3-none-any.whl", hash = "sha256:0427c98284bbfedbdf1686d29729d04b13e13f72017e184057892c8583c2b195"}, + {file = "llama_cloud_services-0.6.1.tar.gz", hash = "sha256:92c7ee4fcc80adaa60f26c0da805182fa56d771fff11e9abb873f9ddb11b5e37"}, ] [package.dependencies] @@ -2628,19 +2680,19 @@ python-dotenv = ">=1.0.1,<2.0.0" [[package]] name = "llama-index" -version = "0.12.16" +version = "0.12.17" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index-0.12.16-py3-none-any.whl", hash = "sha256:c94d0cf6735219d97d91e2eca5bcfac89ec1583990917f934b075d5a45686cf6"}, - {file = "llama_index-0.12.16.tar.gz", hash = "sha256:4fd5f5b94eb3f8dd470bb8cc0e1b985d931e8f31473266ef69855488fd8ae3f2"}, + {file = "llama_index-0.12.17-py3-none-any.whl", hash = "sha256:d8938e5e6e5ff78b6865f7890a01d1a40818a5df798555ee6eb7f2c5ab65aeb0"}, + {file = "llama_index-0.12.17.tar.gz", hash = "sha256:761a2dad3eb74bd5242ecf8fd28337c0c8745fc8d39d2f9f9b18bf733ad679f4"}, ] [package.dependencies] llama-index-agent-openai = ">=0.4.0,<0.5.0" llama-index-cli = ">=0.4.0,<0.5.0" -llama-index-core = ">=0.12.16,<0.13.0" +llama-index-core = ">=0.12.17,<0.13.0" llama-index-embeddings-openai = ">=0.3.0,<0.4.0" llama-index-indices-managed-llama-cloud = ">=0.4.0" llama-index-llms-openai = ">=0.3.0,<0.4.0" @@ -2750,17 +2802,17 @@ llama-index-core = ">=0.12.0,<0.13.0" [[package]] name = "llama-index-llms-openai" -version = "0.3.18" +version = "0.3.19" description = "llama-index llms openai integration" optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_index_llms_openai-0.3.18-py3-none-any.whl", hash = "sha256:e2e78ab94fafda8ac99fbfea1b19c5ba4e49d292557d2bdd9c7cc4b445f8745f"}, - {file = "llama_index_llms_openai-0.3.18.tar.gz", hash = "sha256:81807ba318bac28aca67873228c55242c5fe55f8beba35d23828af6e03b1b234"}, + {file = "llama_index_llms_openai-0.3.19-py3-none-any.whl", hash = "sha256:ad3c4a8c86aef181eba6b34cfff995a7c288d6bd5b99207438e25c051d80532d"}, + {file = "llama_index_llms_openai-0.3.19.tar.gz", hash = "sha256:2e2dad70e7a9cb7a1519be1af4ba60c651a0039bc88888332a17922be00b0299"}, ] [package.dependencies] -llama-index-core = ">=0.12.4,<0.13.0" +llama-index-core = ">=0.12.17,<0.13.0" openai = ">=1.58.1,<2.0.0" [[package]] @@ -2848,17 +2900,17 @@ llama-parse = ">=0.5.0" [[package]] name = "llama-parse" -version = "0.6.0" +version = "0.6.1" description = "Parse files into RAG-Optimized formats." optional = false python-versions = "<4.0,>=3.9" files = [ - {file = "llama_parse-0.6.0-py3-none-any.whl", hash = "sha256:786f3b5cb0afdb784cd3d4b8b03489573b56d7d574212cc88a2eda508965121d"}, - {file = "llama_parse-0.6.0.tar.gz", hash = "sha256:ac54ce4a43929b401a3ae4643e02ba4214e14814efb06062586263e13996ec54"}, + {file = "llama_parse-0.6.1-py3-none-any.whl", hash = "sha256:5f96c2951bc3ad514b67bb6886c99224f567d08290fc016e5c8de22c2df60e90"}, + {file = "llama_parse-0.6.1.tar.gz", hash = "sha256:bd848d3ab7460f70f9e9acaef057fb14ae45f976bdf91830db86a8c40883ef34"}, ] [package.dependencies] -llama-cloud-services = "*" +llama-cloud-services = ">=0.6.1" [[package]] name = "locust" @@ -3376,13 +3428,13 @@ files = [ [[package]] name = "openai" -version = "1.61.1" +version = "1.62.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" files = [ - {file = "openai-1.61.1-py3-none-any.whl", hash = "sha256:72b0826240ce26026ac2cd17951691f046e5be82ad122d20a8e1b30ca18bd11e"}, - {file = "openai-1.61.1.tar.gz", hash = "sha256:ce1851507218209961f89f3520e06726c0aa7d0512386f0f977e3ac3e4f2472e"}, + {file = "openai-1.62.0-py3-none-any.whl", hash = "sha256:dcb7f9fb4fbc3f27e3ffd2d7bf045be9211510d7fafefcef7ad2302cb27484e0"}, + {file = "openai-1.62.0.tar.gz", hash = "sha256:ef3f6864ae2f75fa6296bc9811acf684b95557fcb611fe95734215a8b9150b43"}, ] [package.dependencies] @@ -4192,6 +4244,31 @@ files = [ [package.extras] test = ["cffi", "hypothesis", "pandas", "pytest", "pytz"] +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = true +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + [[package]] name = "pycparser" version = "2.22" @@ -4443,13 +4520,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.393" +version = "1.1.394" description = "Command line wrapper for pyright" optional = true python-versions = ">=3.7" files = [ - {file = "pyright-1.1.393-py3-none-any.whl", hash = "sha256:8320629bb7a44ca90944ba599390162bf59307f3d9fb6e27da3b7011b8c17ae5"}, - {file = "pyright-1.1.393.tar.gz", hash = "sha256:aeeb7ff4e0364775ef416a80111613f91a05c8e01e58ecfefc370ca0db7aed9c"}, + {file = "pyright-1.1.394-py3-none-any.whl", hash = "sha256:5f74cce0a795a295fb768759bbeeec62561215dea657edcaab48a932b031ddbb"}, + {file = "pyright-1.1.394.tar.gz", hash = "sha256:56f2a3ab88c5214a451eb71d8f2792b7700434f841ea219119ade7f42ca93608"}, ] [package.dependencies] @@ -5191,6 +5268,20 @@ files = [ {file = "rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d"}, ] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = true +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "scramp" version = "1.4.5" @@ -5862,83 +5953,80 @@ test = ["websockets"] [[package]] name = "websockets" -version = "12.0" +version = "14.2" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, - {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, - {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, - {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, - {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, - {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, - {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, - {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, - {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, - {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, - {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, - {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, - {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, - {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, - {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, - {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, - {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, - {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, - {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, - {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, - {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, - {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, - {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, - {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, - {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, + {file = "websockets-14.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e8179f95323b9ab1c11723e5d91a89403903f7b001828161b480a7810b334885"}, + {file = "websockets-14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d8c3e2cdb38f31d8bd7d9d28908005f6fa9def3324edb9bf336d7e4266fd397"}, + {file = "websockets-14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:714a9b682deb4339d39ffa674f7b674230227d981a37d5d174a4a83e3978a610"}, + {file = "websockets-14.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2e53c72052f2596fb792a7acd9704cbc549bf70fcde8a99e899311455974ca3"}, + {file = "websockets-14.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3fbd68850c837e57373d95c8fe352203a512b6e49eaae4c2f4088ef8cf21980"}, + {file = "websockets-14.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b27ece32f63150c268593d5fdb82819584831a83a3f5809b7521df0685cd5d8"}, + {file = "websockets-14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4daa0faea5424d8713142b33825fff03c736f781690d90652d2c8b053345b0e7"}, + {file = "websockets-14.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:bc63cee8596a6ec84d9753fd0fcfa0452ee12f317afe4beae6b157f0070c6c7f"}, + {file = "websockets-14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7a570862c325af2111343cc9b0257b7119b904823c675b22d4ac547163088d0d"}, + {file = "websockets-14.2-cp310-cp310-win32.whl", hash = "sha256:75862126b3d2d505e895893e3deac0a9339ce750bd27b4ba515f008b5acf832d"}, + {file = "websockets-14.2-cp310-cp310-win_amd64.whl", hash = "sha256:cc45afb9c9b2dc0852d5c8b5321759cf825f82a31bfaf506b65bf4668c96f8b2"}, + {file = "websockets-14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3bdc8c692c866ce5fefcaf07d2b55c91d6922ac397e031ef9b774e5b9ea42166"}, + {file = "websockets-14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c93215fac5dadc63e51bcc6dceca72e72267c11def401d6668622b47675b097f"}, + {file = "websockets-14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c9b6535c0e2cf8a6bf938064fb754aaceb1e6a4a51a80d884cd5db569886910"}, + {file = "websockets-14.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a52a6d7cf6938e04e9dceb949d35fbdf58ac14deea26e685ab6368e73744e4c"}, + {file = "websockets-14.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f05702e93203a6ff5226e21d9b40c037761b2cfb637187c9802c10f58e40473"}, + {file = "websockets-14.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22441c81a6748a53bfcb98951d58d1af0661ab47a536af08920d129b4d1c3473"}, + {file = "websockets-14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd9b868d78b194790e6236d9cbc46d68aba4b75b22497eb4ab64fa640c3af56"}, + {file = "websockets-14.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a5a20d5843886d34ff8c57424cc65a1deda4375729cbca4cb6b3353f3ce4142"}, + {file = "websockets-14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:34277a29f5303d54ec6468fb525d99c99938607bc96b8d72d675dee2b9f5bf1d"}, + {file = "websockets-14.2-cp311-cp311-win32.whl", hash = "sha256:02687db35dbc7d25fd541a602b5f8e451a238ffa033030b172ff86a93cb5dc2a"}, + {file = "websockets-14.2-cp311-cp311-win_amd64.whl", hash = "sha256:862e9967b46c07d4dcd2532e9e8e3c2825e004ffbf91a5ef9dde519ee2effb0b"}, + {file = "websockets-14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f20522e624d7ffbdbe259c6b6a65d73c895045f76a93719aa10cd93b3de100c"}, + {file = "websockets-14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:647b573f7d3ada919fd60e64d533409a79dcf1ea21daeb4542d1d996519ca967"}, + {file = "websockets-14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6af99a38e49f66be5a64b1e890208ad026cda49355661549c507152113049990"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:091ab63dfc8cea748cc22c1db2814eadb77ccbf82829bac6b2fbe3401d548eda"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b374e8953ad477d17e4851cdc66d83fdc2db88d9e73abf755c94510ebddceb95"}, + {file = "websockets-14.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a39d7eceeea35db85b85e1169011bb4321c32e673920ae9c1b6e0978590012a3"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a6f3efd47ffd0d12080594f434faf1cd2549b31e54870b8470b28cc1d3817d9"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:065ce275e7c4ffb42cb738dd6b20726ac26ac9ad0a2a48e33ca632351a737267"}, + {file = "websockets-14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e9d0e53530ba7b8b5e389c02282f9d2aa47581514bd6049d3a7cffe1385cf5fe"}, + {file = "websockets-14.2-cp312-cp312-win32.whl", hash = "sha256:20e6dd0984d7ca3037afcb4494e48c74ffb51e8013cac71cf607fffe11df7205"}, + {file = "websockets-14.2-cp312-cp312-win_amd64.whl", hash = "sha256:44bba1a956c2c9d268bdcdf234d5e5ff4c9b6dc3e300545cbe99af59dda9dcce"}, + {file = "websockets-14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f1372e511c7409a542291bce92d6c83320e02c9cf392223272287ce55bc224e"}, + {file = "websockets-14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4da98b72009836179bb596a92297b1a61bb5a830c0e483a7d0766d45070a08ad"}, + {file = "websockets-14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8a86a269759026d2bde227652b87be79f8a734e582debf64c9d302faa1e9f03"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86cf1aaeca909bf6815ea714d5c5736c8d6dd3a13770e885aafe062ecbd04f1f"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b0f6c3ba3b1240f602ebb3971d45b02cc12bd1845466dd783496b3b05783a5"}, + {file = "websockets-14.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:669c3e101c246aa85bc8534e495952e2ca208bd87994650b90a23d745902db9a"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eabdb28b972f3729348e632ab08f2a7b616c7e53d5414c12108c29972e655b20"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2066dc4cbcc19f32c12a5a0e8cc1b7ac734e5b64ac0a325ff8353451c4b15ef2"}, + {file = "websockets-14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab95d357cd471df61873dadf66dd05dd4709cae001dd6342edafc8dc6382f307"}, + {file = "websockets-14.2-cp313-cp313-win32.whl", hash = "sha256:a9e72fb63e5f3feacdcf5b4ff53199ec8c18d66e325c34ee4c551ca748623bbc"}, + {file = "websockets-14.2-cp313-cp313-win_amd64.whl", hash = "sha256:b439ea828c4ba99bb3176dc8d9b933392a2413c0f6b149fdcba48393f573377f"}, + {file = "websockets-14.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7cd5706caec1686c5d233bc76243ff64b1c0dc445339bd538f30547e787c11fe"}, + {file = "websockets-14.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ec607328ce95a2f12b595f7ae4c5d71bf502212bddcea528290b35c286932b12"}, + {file = "websockets-14.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da85651270c6bfb630136423037dd4975199e5d4114cae6d3066641adcc9d1c7"}, + {file = "websockets-14.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ecadc7ce90accf39903815697917643f5b7cfb73c96702318a096c00aa71f5"}, + {file = "websockets-14.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1979bee04af6a78608024bad6dfcc0cc930ce819f9e10342a29a05b5320355d0"}, + {file = "websockets-14.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dddacad58e2614a24938a50b85969d56f88e620e3f897b7d80ac0d8a5800258"}, + {file = "websockets-14.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:89a71173caaf75fa71a09a5f614f450ba3ec84ad9fca47cb2422a860676716f0"}, + {file = "websockets-14.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6af6a4b26eea4fc06c6818a6b962a952441e0e39548b44773502761ded8cc1d4"}, + {file = "websockets-14.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:80c8efa38957f20bba0117b48737993643204645e9ec45512579132508477cfc"}, + {file = "websockets-14.2-cp39-cp39-win32.whl", hash = "sha256:2e20c5f517e2163d76e2729104abc42639c41cf91f7b1839295be43302713661"}, + {file = "websockets-14.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4c8cef610e8d7c70dea92e62b6814a8cd24fbd01d7103cc89308d2bfe1659ef"}, + {file = "websockets-14.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d7d9cafbccba46e768be8a8ad4635fa3eae1ffac4c6e7cb4eb276ba41297ed29"}, + {file = "websockets-14.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c76193c1c044bd1e9b3316dcc34b174bbf9664598791e6fb606d8d29000e070c"}, + {file = "websockets-14.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd475a974d5352390baf865309fe37dec6831aafc3014ffac1eea99e84e83fc2"}, + {file = "websockets-14.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6c0097a41968b2e2b54ed3424739aab0b762ca92af2379f152c1aef0187e1c"}, + {file = "websockets-14.2-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7ff794c8b36bc402f2e07c0b2ceb4a2424147ed4785ff03e2a7af03711d60a"}, + {file = "websockets-14.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dec254fcabc7bd488dab64846f588fc5b6fe0d78f641180030f8ea27b76d72c3"}, + {file = "websockets-14.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:bbe03eb853e17fd5b15448328b4ec7fb2407d45fb0245036d06a3af251f8e48f"}, + {file = "websockets-14.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a3c4aa3428b904d5404a0ed85f3644d37e2cb25996b7f096d77caeb0e96a3b42"}, + {file = "websockets-14.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:577a4cebf1ceaf0b65ffc42c54856214165fb8ceeba3935852fc33f6b0c55e7f"}, + {file = "websockets-14.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad1c1d02357b7665e700eca43a31d52814ad9ad9b89b58118bdabc365454b574"}, + {file = "websockets-14.2-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f390024a47d904613577df83ba700bd189eedc09c57af0a904e5c39624621270"}, + {file = "websockets-14.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3c1426c021c38cf92b453cdf371228d3430acd775edee6bac5a4d577efc72365"}, + {file = "websockets-14.2-py3-none-any.whl", hash = "sha256:7a6ceec4ea84469f15cf15807a747e9efe57e369c384fa86e022b3bea679b79b"}, + {file = "websockets-14.2.tar.gz", hash = "sha256:5059ed9c54945efb321f097084b4c7e52c246f2c869815876a69d1efc4ad6eb5"}, ] [[package]] @@ -6492,17 +6580,18 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\ cffi = ["cffi (>=1.11)"] [extras] -all = ["autoflake", "black", "datasets", "docker", "fastapi", "isort", "langchain", "langchain-community", "locust", "pexpect", "pg8000", "pgvector", "pre-commit", "psycopg2", "psycopg2-binary", "pyright", "pytest-asyncio", "pytest-order", "uvicorn", "websockets", "wikipedia"] +all = ["autoflake", "black", "datasets", "docker", "fastapi", "isort", "langchain", "langchain-community", "locust", "pexpect", "pg8000", "pgvector", "pre-commit", "psycopg2", "psycopg2-binary", "pyright", "pytest-asyncio", "pytest-order", "uvicorn", "wikipedia"] bedrock = [] cloud-tool-sandbox = ["e2b-code-interpreter"] dev = ["autoflake", "black", "datasets", "isort", "locust", "pexpect", "pre-commit", "pyright", "pytest-asyncio", "pytest-order"] external-tools = ["docker", "langchain", "langchain-community", "wikipedia"] +google = ["google-genai"] postgres = ["pg8000", "pgvector", "psycopg2", "psycopg2-binary"] qdrant = ["qdrant-client"] -server = ["fastapi", "uvicorn", "websockets"] +server = ["fastapi", "uvicorn"] tests = ["wikipedia"] [metadata] lock-version = "2.0" python-versions = "<3.14,>=3.10" -content-hash = "1cb8ed2407a871e0753b46cd32454b0c78fb166fe60624b12265f800430e6d28" +content-hash = "45a69f6422acba29dff7f93352c2b8a42d1ce6e1a5f1b0549bc86c12a2aee3b6" diff --git a/pyproject.toml b/pyproject.toml index 7921d6edb8..d97ab6b756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ prettytable = "^3.9.0" pgvector = { version = "^0.2.3", optional = true } pre-commit = {version = "^3.5.0", optional = true } pg8000 = {version = "^1.30.3", optional = true} -websockets = {version = "^12.0", optional = true} docstring-parser = ">=0.16,<0.17" httpx = "^0.28.0" numpy = "^1.26.2" @@ -79,6 +78,7 @@ e2b-code-interpreter = {version = "^1.0.3", optional = true} anthropic = "^0.43.0" letta_client = "^0.1.23" openai = "^1.60.0" +google-genai = {version = "^1.1.0", optional = true} faker = "^36.1.0" colorama = "^0.4.6" @@ -93,6 +93,7 @@ external-tools = ["docker", "langchain", "wikipedia", "langchain-community"] tests = ["wikipedia"] all = ["pgvector", "pg8000", "psycopg2-binary", "psycopg2", "pytest", "pytest-asyncio", "pexpect", "black", "pre-commit", "datasets", "pyright", "pytest-order", "autoflake", "isort", "websockets", "fastapi", "uvicorn", "docker", "langchain", "wikipedia", "langchain-community", "locust"] bedrock = ["boto3"] +google = ["google-genai"] [tool.poetry.group.dev.dependencies] black = "^24.4.2" diff --git a/tests/configs/llm_model_configs/gemini-vertex.json b/tests/configs/llm_model_configs/gemini-vertex.json new file mode 100644 index 0000000000..a9a1f2aff9 --- /dev/null +++ b/tests/configs/llm_model_configs/gemini-vertex.json @@ -0,0 +1,7 @@ +{ + "model": "gemini-2.0-pro-exp-02-05", + "model_endpoint_type": "google_vertex", + "model_endpoint": "https://us-central1-aiplatform.googleapis.com/v1/projects/memgpt-428419/locations/us-central1", + "context_window": 2097152, + "put_inner_thoughts_in_kwargs": true +} diff --git a/tests/test_model_letta_performance.py b/tests/test_model_letta_performance.py index bcc5c5f69e..369552c6d2 100644 --- a/tests/test_model_letta_performance.py +++ b/tests/test_model_letta_performance.py @@ -303,6 +303,18 @@ def test_gemini_pro_15_edit_core_memory(): print(f"Got successful response from client: \n\n{response}") +# ====================================================================================================================== +# GOOGLE VERTEX TESTS +# ====================================================================================================================== +@pytest.mark.vertex_basic +@retry_until_success(max_attempts=1, sleep_time_seconds=2) +def test_vertex_gemini_pro_20_returns_valid_first_message(): + filename = os.path.join(llm_config_dir, "gemini-vertex.json") + response = check_first_response_is_valid_for_llm_endpoint(filename) + # Log out successful response + print(f"Got successful response from client: \n\n{response}") + + # ====================================================================================================================== # TOGETHER TESTS # ====================================================================================================================== diff --git a/tests/test_providers.py b/tests/test_providers.py index a575fba544..5dd99fbe43 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -5,6 +5,7 @@ AnthropicProvider, AzureProvider, GoogleAIProvider, + GoogleVertexProvider, GroqProvider, MistralProvider, OllamaProvider, @@ -66,6 +67,16 @@ def test_googleai(): provider.list_embedding_models() +def test_google_vertex(): + provider = GoogleVertexProvider(google_cloud_project=os.getenv("GCP_PROJECT_ID"), google_cloud_location=os.getenv("GCP_REGION")) + models = provider.list_llm_models() + print(models) + print([m.model for m in models]) + + embedding_models = provider.list_embedding_models() + print([m.embedding_model for m in embedding_models]) + + def test_mistral(): provider = MistralProvider(api_key=os.getenv("MISTRAL_API_KEY")) models = provider.list_llm_models() From 93c57d4f0ae76e86b2a197843a2e02a05ddf3b2a Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 12 Feb 2025 22:18:46 -0800 Subject: [PATCH 8/8] feat: add new tutorial notebook and tool rules and visualization (#987) --- examples/notebooks/Visualize Tool Rules.ipynb | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 examples/notebooks/Visualize Tool Rules.ipynb diff --git a/examples/notebooks/Visualize Tool Rules.ipynb b/examples/notebooks/Visualize Tool Rules.ipynb new file mode 100644 index 0000000000..2a13833743 --- /dev/null +++ b/examples/notebooks/Visualize Tool Rules.ipynb @@ -0,0 +1,355 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3511f97a-40ef-4ccf-893d-881db53a917e", + "metadata": {}, + "source": [ + "# Visualizing Tool Rules \n", + "By default, Letta agents can theoretically choose whatever tool they want to call, and whehter or not continue execution. Although we can modify this behavior through prompting, it can sometimes be easier and more reliable to instead constrain the behavior of the agent. \n", + "\n", + "This tutorial will show you how to add *tool rules* to Letta agents and visualize the execution graph. \n", + "\n", + "Make sure you have the following packages installed: \n", + "* `letta-client`\n", + "* `networkx`\n", + "* `matplotlib`" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "332a5f53-c2c7-4b8f-950a-906fb1386962", + "metadata": {}, + "outputs": [], + "source": [ + "from letta_client import Letta, TerminalToolRule, ConditionalToolRule, InitToolRule, ChildToolRule" + ] + }, + { + "cell_type": "markdown", + "id": "31cc2bf2-af49-4a09-9754-1b5ac8c1b6f4", + "metadata": {}, + "source": [ + "## Start the server\n", + "\n", + "Make sure you have a Letta server running that you can connect to. You can have a server running by: \n", + "* Starting the [Letta Desktop](https://docs.letta.com/install) app on your computer \n", + "* Running the [Docker container](https://docs.letta.com/quickstart/docker) " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0fbdd4a5-442b-4095-88f7-bfb9506e362d", + "metadata": {}, + "outputs": [], + "source": [ + "client = Letta(base_url=\"http://localhost:8283\")" + ] + }, + { + "cell_type": "markdown", + "id": "5c65418c-41e6-42bf-b7a6-3d1471f9e0e5", + "metadata": {}, + "source": [ + "## Defining tool rules \n", + "We will use the default Letta tools, but all the following constraints: \n", + "* `archival_memory_search` must be called first when the agent is invoked\n", + "* `conversation_search` must be called if `archival_memory_search` is called\n", + "* If `send_message` is called (what allows the agent to send a message to the user), then the agent will stop execution" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "45a66c16-60f9-4a1e-a36d-ed52714134dc", + "metadata": {}, + "outputs": [], + "source": [ + "agent_state = client.agents.create(\n", + " memory_blocks = [\n", + " {\"label\": \"persona\", \"value\": \"I am a helpful agent\"}, \n", + " {\"label\": \"human\", \"value\": \"Name: Sarah\"}\n", + " ], \n", + " tool_rules = [\n", + " InitToolRule(tool_name=\"archival_memory_search\", type=\"run_first\"), \n", + " ChildToolRule(tool_name=\"archival_memory_search\", children=[\"conversation_search\"], type=\"constrain_child_tools\"), \n", + " TerminalToolRule(tool_name=\"send_message\", type=\"exit_loop\") \n", + " ], \n", + " model=\"openai/gpt-4o-mini\", # specify the handle of the model you want to use\n", + " embedding=\"openai/text-embedding-3-small\" # specify the handle of the embedding model \n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "59ad7756-7a99-4844-81ec-ce26a30d7b85", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[InitToolRule(tool_name='archival_memory_search', type='run_first'),\n", + " ChildToolRule(tool_name='archival_memory_search', type='constrain_child_tools', children=['conversation_search']),\n", + " InitToolRule(tool_name='send_message', type='exit_loop')]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "agent_state.tool_rules" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "26fc7ce0-f8ca-4f30-ab5a-cd031488b3f4", + "metadata": {}, + "outputs": [], + "source": [ + "response = client.agents.messages.create(\n", + " agent_id=agent_state.id,\n", + " messages=[\n", + " {\"role\": \"user\", \"content\": \"hello\"} \n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d0d9222b-5f3a-4211-a190-d317843ecbe4", + "metadata": {}, + "source": [ + "We can see that the agent calls tools in the pattern that we expect: " + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "9598c1dc-8923-4576-a9f8-2389d38c2176", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ToolCallMessage(id='message-e0171629-0fd8-476b-a473-4584b92b4772', date=datetime.datetime(2025, 2, 13, 3, 5, 56, tzinfo=TzInfo(UTC)), message_type='tool_call_message', tool_call=ToolCall(name='archival_memory_search', arguments='{\\n \"query\": \"hello\",\\n \"page\": 0,\\n \"start\": 0,\\n \"request_heartbeat\": true\\n}', tool_call_id='call_16fMrU8J6JJgJbiqrVVt7KTa'))\n", + "ToolReturnMessage(id='message-94624be0-ed62-471d-8c3e-60fea7d56d7f', date=datetime.datetime(2025, 2, 13, 3, 5, 58, tzinfo=TzInfo(UTC)), message_type='tool_return_message', tool_return='([], 0)', status='success', tool_call_id='call_16fMrU8J6JJgJbiqrVVt7KTa', stdout=None, stderr=None)\n", + "ToolCallMessage(id='message-003b0c97-d153-456b-8fec-478d03c6176a', date=datetime.datetime(2025, 2, 13, 3, 5, 59, tzinfo=TzInfo(UTC)), message_type='tool_call_message', tool_call=ToolCall(name='conversation_search', arguments='{\\n \"query\": \"hello\",\\n \"page\": 0,\\n \"request_heartbeat\": true\\n}', tool_call_id='call_SaCTgxuLovFyyIqyxhMzfLaJ'))\n", + "ToolReturnMessage(id='message-82ec1477-1f82-4058-b957-da2edecf5641', date=datetime.datetime(2025, 2, 13, 3, 5, 59, tzinfo=TzInfo(UTC)), message_type='tool_return_message', tool_return='Showing 1 of 1 results (page 0/0): [\\n \"{\\\\n \\\\\"type\\\\\": \\\\\"user_message\\\\\",\\\\n \\\\\"message\\\\\": \\\\\"hello\\\\\",\\\\n \\\\\"time\\\\\": \\\\\"2025-02-12 07:05:54 PM PST-0800\\\\\"\\\\n}\"\\n]', status='success', tool_call_id='call_SaCTgxuLovFyyIqyxhMzfLaJ', stdout=None, stderr=None)\n", + "AssistantMessage(id='message-454127c9-7ee1-46da-8d43-a0b8cf6845c5', date=datetime.datetime(2025, 2, 13, 3, 6, tzinfo=TzInfo(UTC)), message_type='assistant_message', content=\"Hey there! It's great to see you here. How's your day going?\")\n" + ] + } + ], + "source": [ + "from pprint import pprint\n", + "for message in response.messages: \n", + " if message.message_type == \"reasoning_message\": continue \n", + " pprint(message)" + ] + }, + { + "cell_type": "markdown", + "id": "5d1e0d9f-8ec7-43aa-a9b2-a8c46364751d", + "metadata": {}, + "source": [ + "## Visualizing Tool Rules \n", + "We can visualize what tools the agent can call by using the `networkx` library to plot the relationship between tools. " + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "a2ef505b-9b55-4f45-b4e0-247b9419c132", + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx\n", + "import matplotlib.pyplot as plt\n", + "\n", + "def create_tool_sequence_graph(agent_state):\n", + " \"\"\"\n", + " Create a directed graph showing possible tool execution sequences based on given rules.\n", + " \n", + " Args:\n", + " agent_state: Agent state object containing tools and rules\n", + " \"\"\"\n", + " # Create directed graph\n", + " G = nx.DiGraph()\n", + " \n", + " # Add start and end nodes\n", + " G.add_node(\"START\")\n", + " G.add_node(\"END\")\n", + " \n", + " # Add all tools as nodes\n", + " for tool in agent_state.tools:\n", + " G.add_node(tool.name)\n", + " \n", + " # Process rules\n", + " start_tool = None\n", + " exit_tools = set()\n", + " constraints = {}\n", + " \n", + " # First pass: categorize rules\n", + " for rule in agent_state.tool_rules:\n", + " if rule.type == \"run_first\":\n", + " start_tool = rule.tool_name\n", + " elif rule.type == \"exit_loop\":\n", + " exit_tools.add(rule.tool_name)\n", + " elif rule.type == \"constrain_child_tools\":\n", + " constraints[rule.tool_name] = rule.children\n", + " \n", + " # If no start tool specified, connect START to all tools\n", + " if start_tool is None:\n", + " for tool in agent_state.tools:\n", + " G.add_edge(\"START\", tool.name)\n", + " else:\n", + " G.add_edge(\"START\", start_tool)\n", + " \n", + " # Add edges between tools based on rules\n", + " for source in agent_state.tools:\n", + " source_name = source.name\n", + " if source_name in exit_tools:\n", + " # Connect exit tools to END node\n", + " G.add_edge(source_name, \"END\")\n", + " continue\n", + " \n", + " if source_name in constraints:\n", + " # Only add edges to constrained children\n", + " for child in constraints[source_name]:\n", + " G.add_edge(source_name, child)\n", + " else:\n", + " # Add edges to all tools except those that must come first\n", + " G.add_edge(source_name, \"END\")\n", + " for target in agent_state.tools:\n", + " target_name = target.name\n", + " if start_tool and target_name == start_tool:\n", + " continue\n", + " G.add_edge(source_name, target_name)\n", + " \n", + " \n", + " # Create hierarchical layout\n", + " pos = nx.kamada_kawai_layout(G)\n", + " #pos = nx.nx_agraph.graphviz_layout(G, prog=\"dot\")\n", + " # Place START on the far left\n", + " #pos[\"START\"] = (-1, 0)\n", + " \n", + " # Place END on the far right\n", + " #pos[\"END\"] = (1, 0)\n", + " \n", + " # Create figure\n", + " plt.figure(figsize=(15, 10))\n", + " \n", + " # Draw nodes with different colors and sizes\n", + " node_colors = {\n", + " 'START': 'lightgreen',\n", + " 'END': 'lightcoral',\n", + " 'default': 'lightblue'\n", + " }\n", + " \n", + " # Draw regular nodes\n", + " tool_nodes = list(set(G.nodes()) - {'START', 'END'})\n", + " nx.draw_networkx_nodes(G, pos, nodelist=tool_nodes, \n", + " node_color=node_colors['default'], \n", + " node_size=3000, \n", + " node_shape='o')\n", + " \n", + " # Draw START node\n", + " nx.draw_networkx_nodes(G, pos, nodelist=['START'], \n", + " node_color=node_colors['START'], \n", + " node_size=3000, \n", + " node_shape='o')\n", + " \n", + " # Draw END node\n", + " nx.draw_networkx_nodes(G, pos, nodelist=['END'], \n", + " node_color=node_colors['END'], \n", + " node_size=3000, \n", + " node_shape='o')\n", + " \n", + " # Draw edges with arrows\n", + " nx.draw_networkx_edges(G, pos, \n", + " edge_color='gray', \n", + " arrows=True, \n", + " arrowsize=10, \n", + " #arrowstyle='->', \n", + " width=2, node_size=3000)\n", + " \n", + " # Add labels with custom font\n", + " nx.draw_networkx_labels(G, pos, \n", + " font_size=10, \n", + " font_weight='bold', \n", + " font_family='sans-serif')\n", + " \n", + " \n", + " plt.axis('off')\n", + " return G, plt" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "972ca7f8-bc4a-4183-b586-9f0212ade50b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(,\n", + " )" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "create_tool_sequence_graph(agent_state)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0d545261-20db-43de-a057-1243e9b099ff", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "letta-cloud", + "language": "python", + "name": "letta-cloud" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}