diff --git a/Dockerfile b/Dockerfile index e0c70a97..60658e46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,10 @@ ENV LETTA_ENVIRONMENT=${LETTA_ENVIRONMENT} \ POETRY_VIRTUALENVS_CREATE=1 \ POETRY_CACHE_DIR=/tmp/poetry_cache +# Set for other builds +ARG LETTA_VERSION +ENV LETTA_VERSION=${LETTA_VERSION} + WORKDIR /app # Create and activate virtual environment @@ -61,9 +65,6 @@ RUN apt-get update && \ COPY otel/otel-collector-config-file.yaml /etc/otel/config-file.yaml COPY otel/otel-collector-config-clickhouse.yaml /etc/otel/config-clickhouse.yaml -# set experimental flag -ARG LETTA_USE_EXPERIMENTAL=1 - ARG LETTA_ENVIRONMENT=PRODUCTION ENV LETTA_ENVIRONMENT=${LETTA_ENVIRONMENT} \ VIRTUAL_ENV="/app/.venv" \ @@ -73,7 +74,6 @@ ENV LETTA_ENVIRONMENT=${LETTA_ENVIRONMENT} \ POSTGRES_DB=letta \ COMPOSIO_DISABLE_VERSION_CHECK=true -# Set for other builds ARG LETTA_VERSION ENV LETTA_VERSION=${LETTA_VERSION} diff --git a/alembic/versions/9792f94e961d_add_file_processing_status_to_.py b/alembic/versions/9792f94e961d_add_file_processing_status_to_.py new file mode 100644 index 00000000..340e7990 --- /dev/null +++ b/alembic/versions/9792f94e961d_add_file_processing_status_to_.py @@ -0,0 +1,50 @@ +"""Add file processing status to FileMetadata and related indices + +Revision ID: 9792f94e961d +Revises: cdd4a1c11aee +Create Date: 2025-06-05 18:51:57.022594 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9792f94e961d" +down_revision: Union[str, None] = "cdd4a1c11aee" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Create constraint + op.create_unique_constraint("uq_file_contents_file_id", "file_contents", ["file_id"]) + + # Step 2: Add processing_status as nullable first + op.add_column("files", sa.Column("processing_status", sa.String(), nullable=True)) + op.add_column("files", sa.Column("error_message", sa.Text(), nullable=True)) + + # Step 3: Backfill existing rows with 'completed' + op.execute("UPDATE files SET processing_status = 'completed'") + + # Step 4: Make the column non-nullable now that it's backfilled + op.alter_column("files", "processing_status", nullable=False) + + # Step 5: Create indices + op.create_index("ix_files_org_created", "files", ["organization_id", sa.literal_column("created_at DESC")], unique=False) + op.create_index("ix_files_processing_status", "files", ["processing_status"], unique=False) + op.create_index("ix_files_source_created", "files", ["source_id", sa.literal_column("created_at DESC")], unique=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("ix_files_source_created", table_name="files") + op.drop_index("ix_files_processing_status", table_name="files") + op.drop_index("ix_files_org_created", table_name="files") + op.drop_column("files", "error_message") + op.drop_column("files", "processing_status") + op.drop_constraint("uq_file_contents_file_id", "file_contents", type_="unique") + # ### end Alembic commands ### diff --git a/alembic/versions/cdd4a1c11aee_add_file_name_to_fileagent_association_.py b/alembic/versions/cdd4a1c11aee_add_file_name_to_fileagent_association_.py new file mode 100644 index 00000000..164803d5 --- /dev/null +++ b/alembic/versions/cdd4a1c11aee_add_file_name_to_fileagent_association_.py @@ -0,0 +1,63 @@ +"""Add file_name to FileAgent association table and FileContent table + +Revision ID: cdd4a1c11aee +Revises: 614c4e53b66e +Create Date: 2025-06-03 15:35:59.623704 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cdd4a1c11aee" +down_revision: Union[str, None] = "614c4e53b66e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "file_contents", + sa.Column("file_id", sa.String(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True), + sa.Column("is_deleted", sa.Boolean(), server_default=sa.text("FALSE"), nullable=False), + sa.Column("_created_by_id", sa.String(), nullable=True), + sa.Column("_last_updated_by_id", sa.String(), nullable=True), + sa.ForeignKeyConstraint(["file_id"], ["files.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("file_id", "id"), + ) + # add the column, nullable for now + op.add_column("files_agents", sa.Column("file_name", sa.String(), nullable=True)) + + # back-fill using a single UPDATE … FROM join + op.execute( + """ + UPDATE files_agents fa + SET file_name = f.file_name + FROM files f + WHERE fa.file_id = f.id; + """ + ) + + # now make it NOT NULL + op.alter_column("files_agents", "file_name", nullable=False) + op.create_index("ix_files_agents_agent_file_name", "files_agents", ["agent_id", "file_name"], unique=False) + op.create_unique_constraint("uq_files_agents_agent_file_name", "files_agents", ["agent_id", "file_name"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("uq_files_agents_agent_file_name", "files_agents", type_="unique") + op.drop_index("ix_files_agents_agent_file_name", table_name="files_agents") + op.drop_column("files_agents", "file_name") + op.drop_table("file_contents") + # ### end Alembic commands ### diff --git a/letta/__init__.py b/letta/__init__.py index a662834e..161ad019 100644 --- a/letta/__init__.py +++ b/letta/__init__.py @@ -1,6 +1,6 @@ import os -__version__ = "0.8.1" +__version__ = "0.8.2" if os.environ.get("LETTA_VERSION"): __version__ = os.environ["LETTA_VERSION"] diff --git a/letta/agent.py b/letta/agent.py index 899f97ff..fbae9e1c 100644 --- a/letta/agent.py +++ b/letta/agent.py @@ -41,6 +41,7 @@ from letta.log import get_logger from letta.memory import summarize_messages from letta.orm import User from letta.orm.enums import ToolType +from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState, AgentStepResponse, UpdateAgent, get_prompt_template_for_agent_type from letta.schemas.block import BlockUpdate from letta.schemas.embedding_config import EmbeddingConfig @@ -72,7 +73,6 @@ from letta.services.tool_manager import ToolManager from letta.settings import settings, summarizer_settings, model_settings from letta.streaming_interface import StreamingRefreshCLIInterface from letta.system import get_heartbeat, get_token_limit_warning, package_function_response, package_summarize_message, package_user_message -from letta.tracing import log_event, trace_method from letta.utils import count_tokens, get_friendly_error_msg, get_tool_call_id, log_telemetry, parse_json, validate_function_response logger = get_logger(__name__) diff --git a/letta/agents/letta_agent.py b/letta/agents/letta_agent.py index 31f4e3dc..b259373f 100644 --- a/letta/agents/letta_agent.py +++ b/letta/agents/letta_agent.py @@ -14,9 +14,9 @@ from letta.agents.helpers import ( _prepare_in_context_messages_no_persist_async, generate_step_id, ) -from letta.errors import LLMContextWindowExceededError +from letta.errors import ContextWindowExceededError from letta.helpers import ToolRulesSolver -from letta.helpers.datetime_helpers import get_utc_timestamp_ns +from letta.helpers.datetime_helpers import AsyncTimer, get_utc_timestamp_ns, ns_to_ms from letta.helpers.tool_execution_helper import enable_strict_mode from letta.interfaces.anthropic_streaming_interface import AnthropicStreamingInterface from letta.interfaces.openai_streaming_interface import OpenAIStreamingInterface @@ -25,6 +25,9 @@ from letta.llm_api.llm_client_base import LLMClientBase from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.log import get_logger from letta.orm.enums import ToolType +from letta.otel.context import get_ctx_attributes +from letta.otel.metric_registry import MetricRegistry +from letta.otel.tracing import log_event, trace_method, tracer from letta.schemas.agent import AgentState from letta.schemas.enums import MessageRole, MessageStreamStatus from letta.schemas.letta_message_content import OmittedReasoningContent, ReasoningContent, RedactedReasoningContent, TextContent @@ -48,7 +51,7 @@ from letta.services.telemetry_manager import NoopTelemetryManager, TelemetryMana from letta.services.tool_executor.tool_execution_manager import ToolExecutionManager from letta.settings import model_settings from letta.system import package_function_response -from letta.tracing import log_event, trace_method, tracer +from letta.types import JsonDict from letta.utils import log_telemetry, validate_function_response logger = get_logger(__name__) @@ -178,7 +181,7 @@ class LettaAgent(BaseAgent): # log llm request time now = get_utc_timestamp_ns() llm_request_ns = now - step_start - agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": llm_request_ns // 1_000_000}) + agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": ns_to_ms(llm_request_ns)}) response = llm_client.convert_response_to_chat_completion(response_data, in_context_messages, agent_state.llm_config) @@ -210,7 +213,7 @@ class LettaAgent(BaseAgent): # log LLM request time now = get_utc_timestamp_ns() llm_request_ns = now - step_start - agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": llm_request_ns // 1_000_000}) + agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": ns_to_ms(llm_request_ns)}) persisted_messages, should_continue = await self._handle_ai_response( tool_call, @@ -227,7 +230,7 @@ class LettaAgent(BaseAgent): # log step time now = get_utc_timestamp_ns() step_ns = now - step_start - agent_step_span.add_event(name="step_ms", attributes={"duration_ms": step_ns // 1_000_000}) + agent_step_span.add_event(name="step_ms", attributes={"duration_ms": ns_to_ms(step_ns)}) agent_step_span.end() # Log LLM Trace @@ -267,7 +270,7 @@ class LettaAgent(BaseAgent): if request_start_timestamp_ns: now = get_utc_timestamp_ns() request_ns = now - request_start_timestamp_ns - request_span.add_event(name="letta_request_ms", attributes={"duration_ms": request_ns // 1_000_000}) + request_span.add_event(name="letta_request_ms", attributes={"duration_ms": ns_to_ms(request_ns)}) request_span.end() # Return back usage @@ -321,7 +324,7 @@ class LettaAgent(BaseAgent): # log LLM request time now = get_utc_timestamp_ns() llm_request_ns = now - step_start - agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": llm_request_ns // 1_000_000}) + agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": ns_to_ms(llm_request_ns)}) # TODO: add run_id usage.step_count += 1 @@ -363,7 +366,7 @@ class LettaAgent(BaseAgent): # log step time now = get_utc_timestamp_ns() step_ns = now - step_start - agent_step_span.add_event(name="step_ms", attributes={"duration_ms": step_ns // 1_000_000}) + agent_step_span.add_event(name="step_ms", attributes={"duration_ms": ns_to_ms(step_ns)}) agent_step_span.end() # Log LLM Trace @@ -384,7 +387,7 @@ class LettaAgent(BaseAgent): if request_start_timestamp_ns: now = get_utc_timestamp_ns() request_ns = now - request_start_timestamp_ns - request_span.add_event(name="request_ms", attributes={"duration_ms": request_ns // 1_000_000}) + request_span.add_event(name="request_ms", attributes={"duration_ms": ns_to_ms(request_ns)}) request_span.end() # Extend the in context message ids @@ -480,7 +483,7 @@ class LettaAgent(BaseAgent): if first_chunk and request_span is not None: now = get_utc_timestamp_ns() ttft_ns = now - request_start_timestamp_ns - request_span.add_event(name="time_to_first_token_ms", attributes={"ttft_ms": ttft_ns // 1_000_000}) + request_span.add_event(name="time_to_first_token_ms", attributes={"ttft_ms": ns_to_ms(ttft_ns)}) first_chunk = False yield f"data: {chunk.model_dump_json()}\n\n" @@ -490,6 +493,9 @@ class LettaAgent(BaseAgent): usage.completion_tokens += interface.output_tokens usage.prompt_tokens += interface.input_tokens usage.total_tokens += interface.input_tokens + interface.output_tokens + MetricRegistry().message_output_tokens.record( + interface.output_tokens, dict(get_ctx_attributes(), **{"model.name": agent_state.llm_config.model}) + ) # Persist input messages if not already # Special strategy to lower TTFT @@ -500,7 +506,7 @@ class LettaAgent(BaseAgent): # log LLM request time now = get_utc_timestamp_ns() llm_request_ns = now - step_start - agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": llm_request_ns // 1_000_000}) + agent_step_span.add_event(name="llm_request_ms", attributes={"duration_ms": ns_to_ms(llm_request_ns)}) # Process resulting stream content tool_call = interface.get_tool_call_object() @@ -515,8 +521,7 @@ class LettaAgent(BaseAgent): total_tokens=interface.input_tokens + interface.output_tokens, ), reasoning_content=reasoning_content, - pre_computed_assistant_message_id=interface.letta_assistant_message_id, - pre_computed_tool_message_id=interface.letta_tool_message_id, + pre_computed_assistant_message_id=interface.letta_message_id, step_id=step_id, agent_step_span=agent_step_span, ) @@ -526,7 +531,7 @@ class LettaAgent(BaseAgent): # log total step time now = get_utc_timestamp_ns() step_ns = now - step_start - agent_step_span.add_event(name="step_ms", attributes={"duration_ms": step_ns // 1_000_000}) + agent_step_span.add_event(name="step_ms", attributes={"duration_ms": ns_to_ms(step_ns)}) agent_step_span.end() # TODO (cliandy): the stream POST request span has ended at this point, we should tie this to the stream @@ -556,8 +561,8 @@ class LettaAgent(BaseAgent): ), ) - if not use_assistant_message or should_continue: - tool_return = [msg for msg in persisted_messages if msg.role == "tool"][-1].to_letta_messages()[0] + tool_return = [msg for msg in persisted_messages if msg.role == "tool"][-1].to_letta_messages()[0] + if not (use_assistant_message and tool_return.name == "send_message"): yield f"data: {tool_return.model_dump_json()}\n\n" if not should_continue: @@ -577,7 +582,7 @@ class LettaAgent(BaseAgent): if request_start_timestamp_ns: now = get_utc_timestamp_ns() request_ns = now - request_start_timestamp_ns - request_span.add_event(name="letta_request_ms", attributes={"duration_ms": request_ns // 1_000_000}) + request_span.add_event(name="letta_request_ms", attributes={"duration_ms": ns_to_ms(request_ns)}) request_span.end() # TODO: Also yield out a letta usage stats SSE @@ -604,10 +609,16 @@ class LettaAgent(BaseAgent): ) log_event("agent.stream_no_tokens.llm_request.created") + async with AsyncTimer() as timer: + response = await llm_client.request_async(request_data, agent_state.llm_config) + MetricRegistry().llm_execution_time_ms_histogram.record( + timer.elapsed_ms, + dict(get_ctx_attributes(), **{"model.name": agent_state.llm_config.model}), + ) # Attempt LLM request return ( request_data, - await llm_client.request_async(request_data, agent_state.llm_config), + response, current_in_context_messages, new_in_context_messages, ) @@ -654,9 +665,7 @@ class LettaAgent(BaseAgent): if first_chunk and ttft_span is not None: provider_request_start_timestamp_ns = get_utc_timestamp_ns() provider_req_start_ns = provider_request_start_timestamp_ns - request_start_timestamp_ns - ttft_span.add_event( - name="provider_req_start_ns", attributes={"provider_req_start_ms": provider_req_start_ns // 1_000_000} - ) + ttft_span.add_event(name="provider_req_start_ns", attributes={"provider_req_start_ms": ns_to_ms(provider_req_start_ns)}) # Attempt LLM request return ( @@ -692,7 +701,7 @@ class LettaAgent(BaseAgent): llm_config: LLMConfig, force: bool, ) -> List[Message]: - if isinstance(e, LLMContextWindowExceededError): + if isinstance(e, ContextWindowExceededError): return await self._rebuild_context_window( in_context_messages=in_context_messages, new_letta_messages=new_letta_messages, llm_config=llm_config, force=force ) @@ -775,6 +784,7 @@ class LettaAgent(BaseAgent): ToolType.LETTA_SLEEPTIME_CORE, ToolType.LETTA_VOICE_SLEEPTIME_CORE, ToolType.LETTA_BUILTIN, + ToolType.LETTA_FILES_CORE, ToolType.EXTERNAL_COMPOSIO, ToolType.EXTERNAL_MCP, } @@ -810,7 +820,6 @@ class LettaAgent(BaseAgent): usage: UsageStatistics, reasoning_content: Optional[List[Union[TextContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent]]] = None, pre_computed_assistant_message_id: Optional[str] = None, - pre_computed_tool_message_id: Optional[str] = None, step_id: str | None = None, new_in_context_messages: Optional[List[Message]] = None, agent_step_span: Optional["Span"] = None, @@ -822,6 +831,9 @@ class LettaAgent(BaseAgent): """ tool_call_name = tool_call.function.name tool_call_args_str = tool_call.function.arguments + # Temp hack to gracefully handle parallel tool calling attempt, only take first one + if "}{" in tool_call_args_str: + tool_call_args_str = tool_call_args_str.split("}{", 1)[0] + "}" try: tool_args = json.loads(tool_call_args_str) @@ -859,6 +871,7 @@ class LettaAgent(BaseAgent): tool_args=tool_args, agent_state=agent_state, agent_step_span=agent_step_span, + step_id=step_id, ) log_telemetry( self.logger, "_handle_ai_response execute tool finish", tool_execution_result=tool_execution_result, tool_call_id=tool_call_id @@ -926,7 +939,6 @@ class LettaAgent(BaseAgent): add_heartbeat_request_system_message=continue_stepping, reasoning_content=reasoning_content, pre_computed_assistant_message_id=pre_computed_assistant_message_id, - pre_computed_tool_message_id=pre_computed_tool_message_id, step_id=logged_step.id if logged_step else None, # TODO (cliandy): eventually move over other agent loops ) @@ -937,10 +949,15 @@ class LettaAgent(BaseAgent): @trace_method async def _execute_tool( - self, tool_name: str, tool_args: dict, agent_state: AgentState, agent_step_span: Optional["Span"] = None + self, + tool_name: str, + tool_args: JsonDict, + agent_state: AgentState, + agent_step_span: Optional["Span"] = None, + step_id: str | None = None, ) -> "ToolExecutionResult": """ - Executes a tool and returns (result, success_flag). + Executes a tool and returns the ToolExecutionResult. """ from letta.schemas.tool_execution_result import ToolExecutionResult @@ -972,7 +989,10 @@ class LettaAgent(BaseAgent): # TODO: Integrate sandbox result log_event(name=f"start_{tool_name}_execution", attributes=tool_args) tool_execution_result = await tool_execution_manager.execute_tool_async( - function_name=tool_name, function_args=tool_args, tool=target_tool + function_name=tool_name, + function_args=tool_args, + tool=target_tool, + step_id=step_id, ) if agent_step_span: end_time = get_utc_timestamp_ns() @@ -980,7 +1000,7 @@ class LettaAgent(BaseAgent): name="tool_execution_completed", attributes={ "tool_name": target_tool.name, - "duration_ms": (end_time - start_time) // 1_000_000, + "duration_ms": ns_to_ms((end_time - start_time)), "success": tool_execution_result.success_flag, "tool_type": target_tool.tool_type, "tool_id": target_tool.id, diff --git a/letta/agents/letta_agent_batch.py b/letta/agents/letta_agent_batch.py index 6df672df..f0810533 100644 --- a/letta/agents/letta_agent_batch.py +++ b/letta/agents/letta_agent_batch.py @@ -16,6 +16,7 @@ from letta.llm_api.llm_client import LLMClient from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.log import get_logger from letta.orm.enums import ToolType +from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState, AgentStepState from letta.schemas.enums import AgentStepStatus, JobStatus, MessageStreamStatus, ProviderType from letta.schemas.job import JobUpdate @@ -39,7 +40,6 @@ from letta.services.passage_manager import PassageManager from letta.services.sandbox_config_manager import SandboxConfigManager from letta.services.tool_executor.tool_execution_manager import ToolExecutionManager from letta.settings import tool_settings -from letta.tracing import log_event, trace_method logger = get_logger(__name__) @@ -551,7 +551,6 @@ class LettaAgentBatch(BaseAgent): add_heartbeat_request_system_message=False, reasoning_content=reasoning_content, pre_computed_assistant_message_id=None, - pre_computed_tool_message_id=None, llm_batch_item_id=llm_batch_item_id, ) diff --git a/letta/agents/voice_agent.py b/letta/agents/voice_agent.py index 1566a666..b2479eed 100644 --- a/letta/agents/voice_agent.py +++ b/letta/agents/voice_agent.py @@ -1,3 +1,4 @@ +import asyncio import json import uuid from datetime import datetime, timedelta, timezone @@ -81,8 +82,8 @@ class VoiceAgent(BaseAgent): self.summary_block_label = "human" # Cached archival memory/message size - self.num_messages = self.message_manager.size(actor=self.actor, agent_id=agent_id) - self.num_archival_memories = self.passage_manager.size(actor=self.actor, agent_id=agent_id) + self.num_messages = None + self.num_archival_memories = None def init_summarizer(self, agent_state: AgentState) -> Summarizer: if not agent_state.multi_agent_group: @@ -118,13 +119,12 @@ class VoiceAgent(BaseAgent): Main streaming loop that yields partial tokens. Whenever we detect a tool call, we yield from _handle_ai_response as well. """ - print("CALL STREAM") if len(input_messages) != 1 or input_messages[0].role != MessageRole.user: raise ValueError(f"Voice Agent was invoked with multiple input messages or message did not have role `user`: {input_messages}") user_query = input_messages[0].content[0].text - agent_state = self.agent_manager.get_agent_by_id(self.agent_id, actor=self.actor) + agent_state = await self.agent_manager.get_agent_by_id_async(self.agent_id, actor=self.actor) # TODO: Refactor this so it uses our in-house clients # TODO: For now, piggyback off of OpenAI client for ease @@ -140,7 +140,7 @@ class VoiceAgent(BaseAgent): summarizer = self.init_summarizer(agent_state=agent_state) - in_context_messages = self.message_manager.get_messages_by_ids(message_ids=agent_state.message_ids, actor=self.actor) + in_context_messages = await self.message_manager.get_messages_by_ids_async(message_ids=agent_state.message_ids, actor=self.actor) memory_edit_timestamp = get_utc_time() in_context_messages[0].content[0].text = compile_system_message( system_prompt=agent_state.system, @@ -183,10 +183,6 @@ class VoiceAgent(BaseAgent): # Rebuild context window if desired await self._rebuild_context_window(summarizer, in_context_messages, letta_message_db_queue) - # TODO: This may be out of sync, if in between steps users add files - self.num_messages = self.message_manager.size(actor=self.actor, agent_id=agent_state.id) - self.num_archival_memories = self.passage_manager.size(actor=self.actor, agent_id=agent_state.id) - yield "data: [DONE]\n\n" async def _handle_ai_response( @@ -286,14 +282,14 @@ class VoiceAgent(BaseAgent): async def _rebuild_context_window( self, summarizer: Summarizer, in_context_messages: List[Message], letta_message_db_queue: List[Message] ) -> None: - new_letta_messages = self.message_manager.create_many_messages(letta_message_db_queue, actor=self.actor) + new_letta_messages = await self.message_manager.create_many_messages_async(letta_message_db_queue, actor=self.actor) # TODO: Make this more general and configurable, less brittle new_in_context_messages, updated = summarizer.summarize( in_context_messages=in_context_messages, new_letta_messages=new_letta_messages ) - self.agent_manager.set_in_context_messages( + await self.agent_manager.set_in_context_messages_async( agent_id=self.agent_id, message_ids=[m.id for m in new_in_context_messages], actor=self.actor ) @@ -301,9 +297,19 @@ class VoiceAgent(BaseAgent): self, in_context_messages: List[Message], agent_state: AgentState, - num_messages: int | None = None, - num_archival_memories: int | None = None, ) -> List[Message]: + self.num_messages, self.num_archival_memories = await asyncio.gather( + ( + self.message_manager.size_async(actor=self.actor, agent_id=agent_state.id) + if self.num_messages is None + else asyncio.sleep(0, result=self.num_messages) + ), + ( + self.passage_manager.size_async(actor=self.actor, agent_id=agent_state.id) + if self.num_archival_memories is None + else asyncio.sleep(0, result=self.num_archival_memories) + ), + ) return await super()._rebuild_memory_async( in_context_messages, agent_state, num_messages=self.num_messages, num_archival_memories=self.num_archival_memories ) diff --git a/letta/agents/voice_sleeptime_agent.py b/letta/agents/voice_sleeptime_agent.py index f0e11d1e..c8ebeb98 100644 --- a/letta/agents/voice_sleeptime_agent.py +++ b/letta/agents/voice_sleeptime_agent.py @@ -3,6 +3,7 @@ from typing import AsyncGenerator, List, Optional, Tuple, Union from letta.agents.helpers import _create_letta_response, serialize_message_history from letta.agents.letta_agent import LettaAgent from letta.orm.enums import ToolType +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState from letta.schemas.block import BlockUpdate from letta.schemas.enums import MessageStreamStatus @@ -17,7 +18,7 @@ from letta.services.message_manager import MessageManager from letta.services.passage_manager import PassageManager from letta.services.summarizer.enums import SummarizationMode from letta.services.summarizer.summarizer import Summarizer -from letta.tracing import trace_method +from letta.types import JsonDict class VoiceSleeptimeAgent(LettaAgent): @@ -89,9 +90,16 @@ class VoiceSleeptimeAgent(LettaAgent): ) @trace_method - async def _execute_tool(self, tool_name: str, tool_args: dict, agent_state: AgentState, agent_step_span: Optional["Span"] = None): + async def _execute_tool( + self, + tool_name: str, + tool_args: JsonDict, + agent_state: AgentState, + agent_step_span: Optional["Span"] = None, + step_id: str | None = None, + ) -> "ToolExecutionResult": """ - Executes a tool and returns (result, success_flag). + Executes a tool and returns the ToolExecutionResult """ from letta.schemas.tool_execution_result import ToolExecutionResult diff --git a/letta/constants.py b/letta/constants.py index 51b4b766..18f6edbb 100644 --- a/letta/constants.py +++ b/letta/constants.py @@ -21,6 +21,15 @@ LETTA_CORE_TOOL_MODULE_NAME = "letta.functions.function_sets.base" LETTA_MULTI_AGENT_TOOL_MODULE_NAME = "letta.functions.function_sets.multi_agent" LETTA_VOICE_TOOL_MODULE_NAME = "letta.functions.function_sets.voice" LETTA_BUILTIN_TOOL_MODULE_NAME = "letta.functions.function_sets.builtin" +LETTA_FILES_TOOL_MODULE_NAME = "letta.functions.function_sets.files" + +LETTA_TOOL_MODULE_NAMES = [ + LETTA_CORE_TOOL_MODULE_NAME, + LETTA_MULTI_AGENT_TOOL_MODULE_NAME, + LETTA_VOICE_TOOL_MODULE_NAME, + LETTA_BUILTIN_TOOL_MODULE_NAME, + LETTA_FILES_TOOL_MODULE_NAME, +] # String in the error message for when the context window is too large @@ -112,6 +121,9 @@ MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX = re.compile( # Built in tools BUILTIN_TOOLS = ["run_code", "web_search"] +# Built in tools +FILES_TOOLS = ["open_file", "close_file", "grep", "search_files"] + # Set of all built-in Letta tools LETTA_TOOL_SET = set( BASE_TOOLS @@ -121,6 +133,7 @@ LETTA_TOOL_SET = set( + BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS + BUILTIN_TOOLS + + FILES_TOOLS ) @@ -294,6 +307,7 @@ CORE_MEMORY_SOURCE_CHAR_LIMIT: int = 5000 # Function return limits FUNCTION_RETURN_CHAR_LIMIT = 6000 # ~300 words BASE_FUNCTION_RETURN_CHAR_LIMIT = 1000000 # very high (we rely on implementation) +FILE_IS_TRUNCATED_WARNING = "# NOTE: This block is truncated, use functions to view the full content." MAX_PAUSE_HEARTBEATS = 360 # in min @@ -316,3 +330,7 @@ RESERVED_FILENAMES = {"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "LPT1", "LPT2" WEB_SEARCH_CLIP_CONTENT = False WEB_SEARCH_INCLUDE_SCORE = False WEB_SEARCH_SEPARATOR = "\n" + "-" * 40 + "\n" + +REDIS_INCLUDE = "INCLUDE" +REDIS_EXCLUDE = "EXCLUDE" +REDIS_SET_DEFAULT_VAL = "None" diff --git a/letta/data_sources/__init__.py b/letta/data_sources/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/letta/data_sources/redis_client.py b/letta/data_sources/redis_client.py new file mode 100644 index 00000000..436a0afb --- /dev/null +++ b/letta/data_sources/redis_client.py @@ -0,0 +1,282 @@ +import asyncio +from functools import wraps +from typing import Any, Optional, Set, Union + +import redis.asyncio as redis +from redis import RedisError + +from letta.constants import REDIS_EXCLUDE, REDIS_INCLUDE, REDIS_SET_DEFAULT_VAL +from letta.log import get_logger + +logger = get_logger(__name__) + +_client_instance = None + + +class AsyncRedisClient: + """Async Redis client with connection pooling and error handling""" + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: int = 0, + password: Optional[str] = None, + max_connections: int = 50, + decode_responses: bool = True, + socket_timeout: int = 5, + socket_connect_timeout: int = 5, + retry_on_timeout: bool = True, + health_check_interval: int = 30, + ): + """ + Initialize Redis client with connection pool. + + Args: + host: Redis server hostname + port: Redis server port + db: Database number + password: Redis password if required + max_connections: Maximum number of connections in pool + decode_responses: Decode byte responses to strings + socket_timeout: Socket timeout in seconds + socket_connect_timeout: Socket connection timeout + retry_on_timeout: Retry operations on timeout + health_check_interval: Seconds between health checks + """ + self.pool = redis.ConnectionPool( + host=host, + port=port, + db=db, + password=password, + max_connections=max_connections, + decode_responses=decode_responses, + socket_timeout=socket_timeout, + socket_connect_timeout=socket_connect_timeout, + retry_on_timeout=retry_on_timeout, + health_check_interval=health_check_interval, + ) + self._client = None + self._lock = asyncio.Lock() + + async def get_client(self) -> redis.Redis: + """Get or create Redis client instance.""" + if self._client is None: + async with self._lock: + if self._client is None: + self._client = redis.Redis(connection_pool=self.pool) + return self._client + + async def close(self): + """Close Redis connection and cleanup.""" + if self._client: + await self._client.close() + await self.pool.disconnect() + self._client = None + + async def __aenter__(self): + """Async context manager entry.""" + await self.get_client() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + # Health check and connection management + async def ping(self) -> bool: + """Check if Redis is accessible.""" + try: + client = await self.get_client() + await client.ping() + return True + except RedisError: + logger.exception("Redis ping failed") + return False + + async def wait_for_ready(self, timeout: int = 30, interval: float = 0.5): + """Wait for Redis to be ready.""" + start_time = asyncio.get_event_loop().time() + while (asyncio.get_event_loop().time() - start_time) < timeout: + if await self.ping(): + return + await asyncio.sleep(interval) + raise ConnectionError(f"Redis not ready after {timeout} seconds") + + # Retry decorator for resilience + def with_retry(max_attempts: int = 3, delay: float = 0.1): + """Decorator to retry Redis operations on failure.""" + + def decorator(func): + @wraps(func) + async def wrapper(self, *args, **kwargs): + last_error = None + for attempt in range(max_attempts): + try: + return await func(self, *args, **kwargs) + except (ConnectionError, TimeoutError) as e: + last_error = e + if attempt < max_attempts - 1: + await asyncio.sleep(delay * (2**attempt)) + logger.warning(f"Retry {attempt + 1}/{max_attempts} for {func.__name__}: {e}") + raise last_error + + return wrapper + + return decorator + + # Basic operations with error handling + @with_retry() + async def get(self, key: str, default: Any = None) -> Any: + """Get value by key.""" + try: + client = await self.get_client() + return await client.get(key) + except: + return default + + @with_retry() + async def set( + self, + key: str, + value: Union[str, int, float], + ex: Optional[int] = None, + px: Optional[int] = None, + nx: bool = False, + xx: bool = False, + ) -> bool: + """ + Set key-value with options. + + Args: + key: Redis key + value: Value to store + ex: Expire time in seconds + px: Expire time in milliseconds + nx: Only set if key doesn't exist + xx: Only set if key exists + """ + client = await self.get_client() + return await client.set(key, value, ex=ex, px=px, nx=nx, xx=xx) + + @with_retry() + async def delete(self, *keys: str) -> int: + """Delete one or more keys.""" + client = await self.get_client() + return await client.delete(*keys) + + @with_retry() + async def exists(self, *keys: str) -> int: + """Check if keys exist.""" + client = await self.get_client() + return await client.exists(*keys) + + # Set operations + async def sadd(self, key: str, *members: Union[str, int, float]) -> int: + """Add members to set.""" + client = await self.get_client() + return await client.sadd(key, *members) + + async def smembers(self, key: str) -> Set[str]: + """Get all set members.""" + client = await self.get_client() + return await client.smembers(key) + + @with_retry() + async def smismember(self, key: str, values: list[Any] | Any) -> list[int] | int: + """clever!: set member is member""" + try: + client = await self.get_client() + result = await client.smismember(key, values) + return result if isinstance(values, list) else result[0] + except: + return [0] * len(values) if isinstance(values, list) else 0 + + async def srem(self, key: str, *members: Union[str, int, float]) -> int: + """Remove members from set.""" + client = await self.get_client() + return await client.srem(key, *members) + + async def scard(self, key: str) -> int: + client = await self.get_client() + return await client.scard(key) + + # Atomic operations + async def incr(self, key: str) -> int: + """Increment key value.""" + client = await self.get_client() + return await client.incr(key) + + async def decr(self, key: str) -> int: + """Decrement key value.""" + client = await self.get_client() + return await client.decr(key) + + async def check_inclusion_and_exclusion(self, member: str, group: str) -> bool: + exclude_key = f"{group}_{REDIS_EXCLUDE}" + include_key = f"{group}_{REDIS_INCLUDE}" + # 1. if the member IS excluded from the group + if self.exists(exclude_key) and await self.scard(exclude_key) > 1: + return bool(await self.smismember(exclude_key, member)) + # 2. if the group HAS an include set, is the member in that set? + if self.exists(include_key) and await self.scard(include_key) > 1: + return bool(await self.smismember(include_key, member)) + # 3. if the group does NOT HAVE an include set and member NOT excluded + return True + + async def create_inclusion_exclusion_keys(self, group: str) -> None: + redis_client = await self.get_client() + await redis_client.sadd(self._get_group_inclusion_key(group), REDIS_SET_DEFAULT_VAL) + await redis_client.sadd(self._get_group_exclusion_key(group), REDIS_SET_DEFAULT_VAL) + + @staticmethod + def _get_group_inclusion_key(group: str) -> str: + return f"{group}_{REDIS_INCLUDE}" + + @staticmethod + def _get_group_exclusion_key(group: str) -> str: + return f"{group}_{REDIS_EXCLUDE}" + + +class NoopAsyncRedisClient(AsyncRedisClient): + async def get(self, key: str, default: Any = None) -> Any: + return default + + async def exists(self, *keys: str) -> int: + return 0 + + async def sadd(self, key: str, *members: Union[str, int, float]) -> int: + return 0 + + async def smismember(self, key: str, values: list[Any] | Any) -> list[int] | int: + return [0] * len(values) if isinstance(values, list) else 0 + + async def delete(self, *keys: str) -> int: + return 0 + + async def check_inclusion_and_exclusion(self, member: str, group: str) -> bool: + return False + + async def create_inclusion_exclusion_keys(self, group: str) -> None: + return None + + async def scard(self, key: str) -> int: + return 0 + + +async def get_redis_client() -> AsyncRedisClient: + global _client_instance + if _client_instance is None: + try: + from letta.settings import settings + + _client_instance = AsyncRedisClient( + host=settings.redis_host or "localhost", + port=settings.redis_port or 6379, + ) + await _client_instance.wait_for_ready(timeout=5) + logger.info("Redis client initialized") + except Exception as e: + logger.warning(f"Failed to initialize Redis: {e}") + _client_instance = NoopAsyncRedisClient() + return _client_instance diff --git a/letta/errors.py b/letta/errors.py index de00071c..17427ea6 100644 --- a/letta/errors.py +++ b/letta/errors.py @@ -88,10 +88,6 @@ class LLMPermissionDeniedError(LLMError): """Error when permission is denied by LLM service""" -class LLMContextWindowExceededError(LLMError): - """Error when the context length is exceeded.""" - - class LLMNotFoundError(LLMError): """Error when requested resource is not found""" diff --git a/letta/functions/function_sets/files.py b/letta/functions/function_sets/files.py new file mode 100644 index 00000000..d1c59afe --- /dev/null +++ b/letta/functions/function_sets/files.py @@ -0,0 +1,58 @@ +from typing import TYPE_CHECKING, List, Optional, Tuple + +if TYPE_CHECKING: + from letta.schemas.agent import AgentState + from letta.schemas.file import FileMetadata + + +async def open_file(agent_state: "AgentState", file_name: str, view_range: Optional[Tuple[int, int]]) -> str: + """ + Open up a file in core memory. + + Args: + file_name (str): Name of the file to view. + view_range (Optional[Tuple[int, int]]): Optional tuple indicating range to view. + + Returns: + str: A status message + """ + raise NotImplementedError("Tool not implemented. Please contact the Letta team.") + + +async def close_file(agent_state: "AgentState", file_name: str) -> str: + """ + Close a file in core memory. + + Args: + file_name (str): Name of the file to close. + + Returns: + str: A status message + """ + raise NotImplementedError("Tool not implemented. Please contact the Letta team.") + + +async def grep(agent_state: "AgentState", pattern: str) -> str: + """ + Grep tool to search files across data sources with keywords. + + Args: + pattern (str): Keyword or regex pattern to search. + + Returns: + str: Matching lines or summary output. + """ + raise NotImplementedError("Tool not implemented. Please contact the Letta team.") + + +async def search_files(agent_state: "AgentState", query: str) -> List["FileMetadata"]: + """ + Get list of most relevant files across all data sources. + + Args: + query (str): The search query. + + Returns: + List[FileMetadata]: List of matching files. + """ + raise NotImplementedError("Tool not implemented. Please contact the Letta team.") diff --git a/letta/functions/schema_generator.py b/letta/functions/schema_generator.py index db8ae2a0..c846aa9d 100644 --- a/letta/functions/schema_generator.py +++ b/letta/functions/schema_generator.py @@ -1,6 +1,6 @@ import inspect import warnings -from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin +from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin from composio.client.collections import ActionParametersModel from docstring_parser import parse @@ -76,6 +76,23 @@ def type_to_json_schema_type(py_type) -> dict: if get_origin(py_type) is Literal: return {"type": "string", "enum": get_args(py_type)} + # Handle tuple types (specifically fixed-length like Tuple[int, int]) + if origin in (tuple, Tuple): + args = get_args(py_type) + if len(args) == 0: + raise ValueError("Tuple type must have at least one element") + + # Support only fixed-length tuples like Tuple[int, int], not variable-length like Tuple[int, ...] + if len(args) == 2 and args[1] is Ellipsis: + raise NotImplementedError("Variable-length tuples (e.g., Tuple[int, ...]) are not supported") + + return { + "type": "array", + "prefixItems": [type_to_json_schema_type(arg) for arg in args], + "minItems": len(args), + "maxItems": len(args), + } + # Handle object types if py_type == dict or origin in (dict, Dict): args = get_args(py_type) diff --git a/letta/groups/sleeptime_multi_agent_v2.py b/letta/groups/sleeptime_multi_agent_v2.py index 7d46ae43..587a8ef8 100644 --- a/letta/groups/sleeptime_multi_agent_v2.py +++ b/letta/groups/sleeptime_multi_agent_v2.py @@ -5,6 +5,7 @@ from typing import AsyncGenerator, List, Optional from letta.agents.base_agent import BaseAgent from letta.agents.letta_agent import LettaAgent from letta.groups.helpers import stringify_message +from letta.otel.tracing import trace_method from letta.schemas.enums import JobStatus from letta.schemas.group import Group, ManagerType from letta.schemas.job import JobUpdate @@ -21,7 +22,6 @@ from letta.services.message_manager import MessageManager from letta.services.passage_manager import PassageManager from letta.services.step_manager import NoopStepManager, StepManager from letta.services.telemetry_manager import NoopTelemetryManager, TelemetryManager -from letta.tracing import trace_method class SleeptimeMultiAgentV2(BaseAgent): diff --git a/letta/helpers/datetime_helpers.py b/letta/helpers/datetime_helpers.py index 60d7d12a..9c5cd825 100644 --- a/letta/helpers/datetime_helpers.py +++ b/letta/helpers/datetime_helpers.py @@ -1,7 +1,9 @@ import re import time -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone from time import strftime +from typing import Callable import pytz @@ -66,7 +68,7 @@ def get_local_time(timezone=None): def get_utc_time() -> datetime: """Get the current UTC time""" # return datetime.now(pytz.utc) - return datetime.now(timezone.utc) + return datetime.now(dt_timezone.utc) def get_utc_time_int() -> int: @@ -78,9 +80,13 @@ def get_utc_timestamp_ns() -> int: return int(time.time_ns()) +def ns_to_ms(ns: int) -> int: + return ns // 1_000_000 + + def timestamp_to_datetime(timestamp_seconds: int) -> datetime: """Convert Unix timestamp in seconds to UTC datetime object""" - return datetime.fromtimestamp(timestamp_seconds, tz=timezone.utc) + return datetime.fromtimestamp(timestamp_seconds, tz=dt_timezone.utc) def format_datetime(dt): @@ -105,3 +111,41 @@ def extract_date_from_timestamp(timestamp): def is_utc_datetime(dt: datetime) -> bool: return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) == timedelta(0) + + +class AsyncTimer: + """An async context manager for timing async code execution. + + Takes in an optional callback_func to call on exit with arguments + taking in the elapsed_ms and exc if present. + + Do not use the start and end times outside of this function as they are relative. + """ + + def __init__(self, callback_func: Callable | None = None): + self._start_time_ns = None + self._end_time_ns = None + self.elapsed_ns = None + self.callback_func = callback_func + + async def __aenter__(self): + self._start_time_ns = time.perf_counter_ns() + return self + + async def __aexit__(self, exc_type, exc, tb): + self._end_time_ns = time.perf_counter_ns() + self.elapsed_ns = self._end_time_ns - self._start_time_ns + if self.callback_func: + from asyncio import iscoroutinefunction + + if iscoroutinefunction(self.callback_func): + await self.callback_func(self.elapsed_ms, exc) + else: + self.callback_func(self.elapsed_ms, exc) + return False + + @property + def elapsed_ms(self): + if self.elapsed_ns is not None: + return ns_to_ms(self.elapsed_ns) + return None diff --git a/letta/helpers/decorators.py b/letta/helpers/decorators.py new file mode 100644 index 00000000..ae20b4f6 --- /dev/null +++ b/letta/helpers/decorators.py @@ -0,0 +1,69 @@ +import inspect +from functools import wraps +from typing import Callable + +from letta.log import get_logger +from letta.plugins.plugins import get_experimental_checker +from letta.settings import settings + +logger = get_logger(__name__) + + +def experimental(feature_name: str, fallback_function: Callable, **kwargs): + """Decorator that runs a fallback function if experimental feature is not enabled. + + - kwargs from the decorator will be combined with function kwargs and overwritten only for experimental evaluation. + - if the decorated function, fallback_function, or experimental checker function is async, the whole call will be async + """ + + def decorator(f): + experimental_checker = get_experimental_checker() + is_f_async = inspect.iscoroutinefunction(f) + is_fallback_async = inspect.iscoroutinefunction(fallback_function) + is_experimental_checker_async = inspect.iscoroutinefunction(experimental_checker) + + async def call_function(func, is_async, *args, **_kwargs): + if is_async: + return await func(*args, **_kwargs) + return func(*args, **_kwargs) + + # asynchronous wrapper if any function is async + if any((is_f_async, is_fallback_async, is_experimental_checker_async)): + + @wraps(f) + async def async_wrapper(*args, **_kwargs): + result = await call_function(experimental_checker, is_experimental_checker_async, feature_name, **dict(_kwargs, **kwargs)) + if result: + return await call_function(f, is_f_async, *args, **_kwargs) + else: + return await call_function(fallback_function, is_fallback_async, *args, **_kwargs) + + return async_wrapper + + else: + + @wraps(f) + def wrapper(*args, **_kwargs): + if experimental_checker(feature_name, **dict(_kwargs, **kwargs)): + return f(*args, **_kwargs) + else: + return fallback_function(*args, **kwargs) + + return wrapper + + return decorator + + +def deprecated(message: str): + """Simple decorator that marks a method as deprecated.""" + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + if settings.debug: + logger.warning(f"Function {f.__name__} is deprecated: {message}.") + return f(*args, **kwargs) + + return wrapper + + return decorator diff --git a/letta/services/helpers/noop_helper.py b/letta/helpers/singleton.py similarity index 73% rename from letta/services/helpers/noop_helper.py rename to letta/helpers/singleton.py index 7f32e628..1d382457 100644 --- a/letta/services/helpers/noop_helper.py +++ b/letta/helpers/singleton.py @@ -1,7 +1,12 @@ +# TODO (cliandy): consolidate with decorators later +from functools import wraps + + def singleton(cls): """Decorator to make a class a Singleton class.""" instances = {} + @wraps(cls) def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) diff --git a/letta/interfaces/anthropic_streaming_interface.py b/letta/interfaces/anthropic_streaming_interface.py index 3d9c24da..65489166 100644 --- a/letta/interfaces/anthropic_streaming_interface.py +++ b/letta/interfaces/anthropic_streaming_interface.py @@ -23,7 +23,7 @@ from anthropic.types.beta import ( ) from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG -from letta.helpers.datetime_helpers import get_utc_timestamp_ns +from letta.helpers.datetime_helpers import get_utc_timestamp_ns, ns_to_ms from letta.local_llm.constants import INNER_THOUGHTS_KWARG from letta.log import get_logger from letta.schemas.letta_message import ( @@ -62,8 +62,7 @@ class AnthropicStreamingInterface: self.use_assistant_message = use_assistant_message # Premake IDs for database writes - self.letta_assistant_message_id = Message.generate_id() - self.letta_tool_message_id = Message.generate_id() + self.letta_message_id = Message.generate_id() self.anthropic_mode = None self.message_id = None @@ -132,7 +131,7 @@ class AnthropicStreamingInterface: now = get_utc_timestamp_ns() ttft_ns = now - provider_request_start_timestamp_ns ttft_span.add_event( - name="anthropic_time_to_first_token_ms", attributes={"anthropic_time_to_first_token_ms": ttft_ns // 1_000_000} + name="anthropic_time_to_first_token_ms", attributes={"anthropic_time_to_first_token_ms": ns_to_ms(ttft_ns)} ) first_chunk = False @@ -152,7 +151,7 @@ class AnthropicStreamingInterface: if not self.use_assistant_message: # Buffer the initial tool call message instead of yielding immediately tool_call_msg = ToolCallMessage( - id=self.letta_tool_message_id, + id=self.letta_message_id, tool_call=ToolCallDelta(name=self.tool_call_name, tool_call_id=self.tool_call_id), date=datetime.now(timezone.utc).isoformat(), ) @@ -165,11 +164,11 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "hidden_reasoning_message": message_index += 1 hidden_reasoning_message = HiddenReasoningMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, state="redacted", hidden_reasoning=content.data, date=datetime.now(timezone.utc).isoformat(), - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) self.reasoning_messages.append(hidden_reasoning_message) prev_message_type = hidden_reasoning_message.message_type @@ -206,10 +205,10 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "reasoning_message": message_index += 1 reasoning_message = ReasoningMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, reasoning=self.accumulated_inner_thoughts[-1], date=datetime.now(timezone.utc).isoformat(), - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) self.reasoning_messages.append(reasoning_message) prev_message_type = reasoning_message.message_type @@ -233,10 +232,10 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "reasoning_message": message_index += 1 reasoning_message = ReasoningMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, reasoning=inner_thoughts_diff, date=datetime.now(timezone.utc).isoformat(), - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) self.reasoning_messages.append(reasoning_message) prev_message_type = reasoning_message.message_type @@ -249,10 +248,28 @@ class AnthropicStreamingInterface: if len(self.tool_call_buffer) > 0: if prev_message_type and prev_message_type != "tool_call_message": message_index += 1 + + # Strip out the inner thoughts from the buffered tool call arguments before streaming + tool_call_args = "" for buffered_msg in self.tool_call_buffer: - buffered_msg.otid = Message.generate_otid_from_id(self.letta_tool_message_id, message_index) - prev_message_type = buffered_msg.message_type - yield buffered_msg + tool_call_args += buffered_msg.tool_call.arguments if buffered_msg.tool_call.arguments else "" + tool_call_args = tool_call_args.replace(f'"{INNER_THOUGHTS_KWARG}": "{current_inner_thoughts}"', "") + + tool_call_msg = ToolCallMessage( + id=self.tool_call_buffer[0].id, + otid=Message.generate_otid_from_id(self.tool_call_buffer[0].id, message_index), + date=self.tool_call_buffer[0].date, + name=self.tool_call_buffer[0].name, + sender_id=self.tool_call_buffer[0].sender_id, + step_id=self.tool_call_buffer[0].step_id, + tool_call=ToolCallDelta( + name=self.tool_call_name, + tool_call_id=self.tool_call_id, + arguments=tool_call_args, + ), + ) + prev_message_type = tool_call_msg.message_type + yield tool_call_msg self.tool_call_buffer = [] # Start detecting special case of "send_message" @@ -266,24 +283,26 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "assistant_message": message_index += 1 assistant_msg = AssistantMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, content=[TextContent(text=send_message_diff)], date=datetime.now(timezone.utc).isoformat(), - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = assistant_msg.message_type yield assistant_msg else: # Otherwise, it is a normal tool call - buffer or yield based on inner thoughts status tool_call_msg = ToolCallMessage( - id=self.letta_tool_message_id, - tool_call=ToolCallDelta(arguments=delta.partial_json), + id=self.letta_message_id, + tool_call=ToolCallDelta( + name=self.tool_call_name, tool_call_id=self.tool_call_id, arguments=delta.partial_json + ), date=datetime.now(timezone.utc).isoformat(), ) if self.inner_thoughts_complete: if prev_message_type and prev_message_type != "tool_call_message": message_index += 1 - tool_call_msg.otid = Message.generate_otid_from_id(self.letta_tool_message_id, message_index) + tool_call_msg.otid = Message.generate_otid_from_id(self.letta_message_id, message_index) prev_message_type = tool_call_msg.message_type yield tool_call_msg else: @@ -301,11 +320,11 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "reasoning_message": message_index += 1 reasoning_message = ReasoningMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, source="reasoner_model", reasoning=delta.thinking, date=datetime.now(timezone.utc).isoformat(), - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) self.reasoning_messages.append(reasoning_message) prev_message_type = reasoning_message.message_type @@ -320,12 +339,12 @@ class AnthropicStreamingInterface: if prev_message_type and prev_message_type != "reasoning_message": message_index += 1 reasoning_message = ReasoningMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, source="reasoner_model", reasoning="", date=datetime.now(timezone.utc).isoformat(), signature=delta.signature, - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) self.reasoning_messages.append(reasoning_message) prev_message_type = reasoning_message.message_type @@ -360,7 +379,7 @@ class AnthropicStreamingInterface: group: List[Union[ReasoningMessage, HiddenReasoningMessage]], group_type: str ) -> Union[TextContent, ReasoningContent, RedactedReasoningContent]: if group_type == "reasoning": - reasoning_text = "".join(chunk.reasoning for chunk in group) + reasoning_text = "".join(chunk.reasoning for chunk in group).strip() is_native = any(chunk.source == "reasoner_model" for chunk in group) signature = next((chunk.signature for chunk in group if chunk.signature is not None), None) if is_native: diff --git a/letta/interfaces/openai_streaming_interface.py b/letta/interfaces/openai_streaming_interface.py index 86397ad8..36c583c6 100644 --- a/letta/interfaces/openai_streaming_interface.py +++ b/letta/interfaces/openai_streaming_interface.py @@ -5,7 +5,7 @@ from openai import AsyncStream from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG -from letta.helpers.datetime_helpers import get_utc_timestamp_ns +from letta.helpers.datetime_helpers import get_utc_timestamp_ns, ns_to_ms from letta.schemas.letta_message import AssistantMessage, LettaMessage, ReasoningMessage, ToolCallDelta, ToolCallMessage from letta.schemas.letta_message_content import TextContent from letta.schemas.message import Message @@ -32,14 +32,14 @@ class OpenAIStreamingInterface: self.function_args_buffer = None self.function_id_buffer = None self.last_flushed_function_name = None + self.last_flushed_function_id = None # Buffer to hold function arguments until inner thoughts are complete self.current_function_arguments = "" self.current_json_parse_result = {} # Premake IDs for database writes - self.letta_assistant_message_id = Message.generate_id() - self.letta_tool_message_id = Message.generate_id() + self.letta_message_id = Message.generate_id() self.message_id = None self.model = None @@ -54,14 +54,14 @@ class OpenAIStreamingInterface: self.reasoning_messages = [] def get_reasoning_content(self) -> List[TextContent]: - content = "".join(self.reasoning_messages) + content = "".join(self.reasoning_messages).strip() return [TextContent(text=content)] def get_tool_call_object(self) -> ToolCall: """Useful for agent loop""" function_name = self.last_flushed_function_name if self.last_flushed_function_name else self.function_name_buffer return ToolCall( - id=self.letta_tool_message_id, + id=self.last_flushed_function_id, function=FunctionCall(arguments=self.current_function_arguments, name=function_name), ) @@ -85,7 +85,7 @@ class OpenAIStreamingInterface: now = get_utc_timestamp_ns() ttft_ns = now - provider_request_start_timestamp_ns ttft_span.add_event( - name="openai_time_to_first_token_ms", attributes={"openai_time_to_first_token_ms": ttft_ns // 1_000_000} + name="openai_time_to_first_token_ms", attributes={"openai_time_to_first_token_ms": ns_to_ms(ttft_ns)} ) first_chunk = False @@ -133,11 +133,11 @@ class OpenAIStreamingInterface: message_index += 1 self.reasoning_messages.append(updates_inner_thoughts) reasoning_message = ReasoningMessage( - id=self.letta_tool_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), reasoning=updates_inner_thoughts, # name=name, - otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = reasoning_message.message_type yield reasoning_message @@ -171,20 +171,22 @@ class OpenAIStreamingInterface: message_index += 1 self.tool_call_name = str(self.function_name_buffer) tool_call_msg = ToolCallMessage( - id=self.letta_tool_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), tool_call=ToolCallDelta( name=self.function_name_buffer, arguments=None, tool_call_id=self.function_id_buffer, ), - otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = tool_call_msg.message_type yield tool_call_msg # Record what the last function name we flushed was self.last_flushed_function_name = self.function_name_buffer + if self.last_flushed_function_id is None: + self.last_flushed_function_id = self.function_id_buffer # Clear the buffer self.function_name_buffer = None self.function_id_buffer = None @@ -236,10 +238,10 @@ class OpenAIStreamingInterface: if prev_message_type and prev_message_type != "assistant_message": message_index += 1 assistant_message = AssistantMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), content=combined_chunk, - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = assistant_message.message_type yield assistant_message @@ -268,11 +270,11 @@ class OpenAIStreamingInterface: if prev_message_type and prev_message_type != "assistant_message": message_index += 1 assistant_message = AssistantMessage( - id=self.letta_assistant_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), content=diff, # name=name, - otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = assistant_message.message_type yield assistant_message @@ -292,15 +294,15 @@ class OpenAIStreamingInterface: if prev_message_type and prev_message_type != "tool_call_message": message_index += 1 tool_call_msg = ToolCallMessage( - id=self.letta_tool_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), tool_call=ToolCallDelta( - name=None, + name=self.function_name_buffer, arguments=combined_chunk, tool_call_id=self.function_id_buffer, ), # name=name, - otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = tool_call_msg.message_type yield tool_call_msg @@ -312,7 +314,7 @@ class OpenAIStreamingInterface: if prev_message_type and prev_message_type != "tool_call_message": message_index += 1 tool_call_msg = ToolCallMessage( - id=self.letta_tool_message_id, + id=self.letta_message_id, date=datetime.now(timezone.utc), tool_call=ToolCallDelta( name=None, @@ -320,7 +322,7 @@ class OpenAIStreamingInterface: tool_call_id=self.function_id_buffer, ), # name=name, - otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index), + otid=Message.generate_otid_from_id(self.letta_message_id, message_index), ) prev_message_type = tool_call_msg.message_type yield tool_call_msg diff --git a/letta/llm_api/anthropic.py b/letta/llm_api/anthropic.py index 4317c3b0..d7a594a2 100644 --- a/letta/llm_api/anthropic.py +++ b/letta/llm_api/anthropic.py @@ -26,6 +26,7 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages from letta.log import get_logger +from letta.otel.tracing import log_event from letta.schemas.enums import ProviderCategory from letta.schemas.message import Message as _Message from letta.schemas.message import MessageRole as _MessageRole @@ -45,7 +46,6 @@ from letta.services.provider_manager import ProviderManager from letta.services.user_manager import UserManager from letta.settings import model_settings from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface -from letta.tracing import log_event logger = get_logger(__name__) diff --git a/letta/llm_api/anthropic_client.py b/letta/llm_api/anthropic_client.py index eefee965..121955d7 100644 --- a/letta/llm_api/anthropic_client.py +++ b/letta/llm_api/anthropic_client.py @@ -27,16 +27,16 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions, unpack_all_in from letta.llm_api.llm_client_base import LLMClientBase from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.schemas.enums import ProviderCategory from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as PydanticMessage -from letta.schemas.openai.chat_completion_request import Tool +from letta.schemas.openai.chat_completion_request import Tool as OpenAITool from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, FunctionCall from letta.schemas.openai.chat_completion_response import Message as ChoiceMessage from letta.schemas.openai.chat_completion_response import ToolCall, UsageStatistics from letta.services.provider_manager import ProviderManager from letta.settings import model_settings -from letta.tracing import trace_method DUMMY_FIRST_USER_MESSAGE = "User initializing bootup sequence." @@ -199,10 +199,10 @@ class AnthropicClient(LLMClientBase): elif llm_config.enable_reasoner: # NOTE: reasoning models currently do not allow for `any` tool_choice = {"type": "auto", "disable_parallel_tool_use": True} - tools_for_request = [Tool(function=f) for f in tools] + tools_for_request = [OpenAITool(function=f) for f in tools] elif force_tool_call is not None: tool_choice = {"type": "tool", "name": force_tool_call} - tools_for_request = [Tool(function=f) for f in tools if f["name"] == force_tool_call] + tools_for_request = [OpenAITool(function=f) for f in tools if f["name"] == force_tool_call] # need to have this setting to be able to put inner thoughts in kwargs if not llm_config.put_inner_thoughts_in_kwargs: @@ -216,7 +216,7 @@ class AnthropicClient(LLMClientBase): tool_choice = {"type": "any", "disable_parallel_tool_use": True} else: tool_choice = {"type": "auto", "disable_parallel_tool_use": True} - tools_for_request = [Tool(function=f) for f in tools] if tools is not None else None + tools_for_request = [OpenAITool(function=f) for f in tools] if tools is not None else None # Add tool choice if tool_choice: @@ -230,7 +230,7 @@ class AnthropicClient(LLMClientBase): inner_thoughts_key=INNER_THOUGHTS_KWARG, inner_thoughts_description=INNER_THOUGHTS_KWARG_DESCRIPTION, ) - tools_for_request = [Tool(function=f) for f in tools_with_inner_thoughts] + tools_for_request = [OpenAITool(function=f) for f in tools_with_inner_thoughts] if tools_for_request and len(tools_for_request) > 0: # TODO eventually enable parallel tool use @@ -270,7 +270,7 @@ class AnthropicClient(LLMClientBase): return data - async def count_tokens(self, messages: List[dict] = None, model: str = None, tools: List[Tool] = None) -> int: + async def count_tokens(self, messages: List[dict] = None, model: str = None, tools: List[OpenAITool] = None) -> int: client = anthropic.AsyncAnthropic() if messages and len(messages) == 0: messages = None @@ -278,11 +278,19 @@ class AnthropicClient(LLMClientBase): anthropic_tools = convert_tools_to_anthropic_format(tools) else: anthropic_tools = None - result = await client.beta.messages.count_tokens( - model=model or "claude-3-7-sonnet-20250219", - messages=messages or [{"role": "user", "content": "hi"}], - tools=anthropic_tools or [], - ) + + try: + result = await client.beta.messages.count_tokens( + model=model or "claude-3-7-sonnet-20250219", + messages=messages or [{"role": "user", "content": "hi"}], + tools=anthropic_tools or [], + ) + except: + import ipdb + + ipdb.set_trace() + raise + token_count = result.input_tokens if messages is None: token_count -= 8 @@ -477,7 +485,7 @@ class AnthropicClient(LLMClientBase): return chat_completion_response -def convert_tools_to_anthropic_format(tools: List[Tool]) -> List[dict]: +def convert_tools_to_anthropic_format(tools: List[OpenAITool]) -> List[dict]: """See: https://docs.anthropic.com/claude/docs/tool-use OpenAI style: @@ -527,7 +535,7 @@ def convert_tools_to_anthropic_format(tools: List[Tool]) -> List[dict]: for tool in tools: formatted_tool = { "name": tool.function.name, - "description": tool.function.description, + "description": tool.function.description if tool.function.description else "", "input_schema": tool.function.parameters or {"type": "object", "properties": {}, "required": []}, } formatted_tools.append(formatted_tool) diff --git a/letta/llm_api/google_vertex_client.py b/letta/llm_api/google_vertex_client.py index 60e800a6..25b1e00f 100644 --- a/letta/llm_api/google_vertex_client.py +++ b/letta/llm_api/google_vertex_client.py @@ -12,12 +12,12 @@ from letta.llm_api.llm_client_base import LLMClientBase from letta.local_llm.json_parser import clean_json_string_extra_backslash from letta.local_llm.utils import count_tokens from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as PydanticMessage 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.settings import model_settings, settings -from letta.tracing import trace_method from letta.utils import get_tool_call_id logger = get_logger(__name__) diff --git a/letta/llm_api/helpers.py b/letta/llm_api/helpers.py index ed497e09..749ae974 100644 --- a/letta/llm_api/helpers.py +++ b/letta/llm_api/helpers.py @@ -63,11 +63,11 @@ def _convert_to_structured_output_helper(property: dict) -> dict: def convert_to_structured_output(openai_function: dict, allow_optional: bool = False) -> dict: - """Convert function call objects to structured output objects + """Convert function call objects to structured output objects. See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas """ - description = openai_function["description"] if "description" in openai_function else "" + description = openai_function.get("description", "") structured_output = { "name": openai_function["name"], @@ -81,54 +81,58 @@ def convert_to_structured_output(openai_function: dict, allow_optional: bool = F }, } - # This code needs to be able to handle nested properties - # For example, the param details may have "type" + "description", - # but if "type" is "object" we expected "properties", where each property has details - # and if "type" is "array" we expect "items": for param, details in openai_function["parameters"]["properties"].items(): param_type = details["type"] - description = details.get("description", "") + param_description = details.get("description", "") if param_type == "object": if "properties" not in details: - # Structured outputs requires the properties on dicts be specified ahead of time - raise ValueError(f"Property {param} of type object is missing properties") + raise ValueError(f"Property {param} of type object is missing 'properties'") structured_output["parameters"]["properties"][param] = { "type": "object", - "description": description, + "description": param_description, "properties": {k: _convert_to_structured_output_helper(v) for k, v in details["properties"].items()}, "additionalProperties": False, "required": list(details["properties"].keys()), } elif param_type == "array": - structured_output["parameters"]["properties"][param] = { - "type": "array", - "description": description, - "items": _convert_to_structured_output_helper(details["items"]), - } + items_schema = details.get("items") + prefix_items_schema = details.get("prefixItems") + + if prefix_items_schema: + # assume fixed-length tuple — safe fallback to use first type for items + fallback_item = prefix_items_schema[0] if isinstance(prefix_items_schema, list) else prefix_items_schema + structured_output["parameters"]["properties"][param] = { + "type": "array", + "description": param_description, + "prefixItems": [_convert_to_structured_output_helper(item) for item in prefix_items_schema], + "items": _convert_to_structured_output_helper(fallback_item), + "minItems": details.get("minItems", len(prefix_items_schema)), + "maxItems": details.get("maxItems", len(prefix_items_schema)), + } + elif items_schema: + structured_output["parameters"]["properties"][param] = { + "type": "array", + "description": param_description, + "items": _convert_to_structured_output_helper(items_schema), + } + else: + raise ValueError(f"Array param '{param}' is missing both 'items' and 'prefixItems'") else: - structured_output["parameters"]["properties"][param] = { - "type": param_type, # simple type - "description": description, + prop = { + "type": param_type, + "description": param_description, } - - if "enum" in details: - structured_output["parameters"]["properties"][param]["enum"] = details["enum"] + if "enum" in details: + prop["enum"] = details["enum"] + structured_output["parameters"]["properties"][param] = prop if not allow_optional: - # Add all properties to required list structured_output["parameters"]["required"] = list(structured_output["parameters"]["properties"].keys()) - else: - # See what parameters exist that aren't required - # Those are implied "optional" types - # For those types, turn each of them into a union type with "null" - # e.g. - # "type": "string" -> "type": ["string", "null"] - # TODO - raise NotImplementedError + raise NotImplementedError("Optional parameter handling is not implemented.") return structured_output @@ -292,6 +296,8 @@ def unpack_inner_thoughts_from_kwargs(choice: Choice, inner_thoughts_key: str) - except json.JSONDecodeError as e: warnings.warn(f"Failed to strip inner thoughts from kwargs: {e}") + print(f"\nFailed to strip inner thoughts from kwargs: {e}") + print(f"\nTool call arguments: {tool_call.function.arguments}") raise e else: warnings.warn(f"Did not find tool call in message: {str(message)}") diff --git a/letta/llm_api/llm_api_tools.py b/letta/llm_api/llm_api_tools.py index a1af262f..62d3bf3b 100644 --- a/letta/llm_api/llm_api_tools.py +++ b/letta/llm_api/llm_api_tools.py @@ -26,6 +26,7 @@ from letta.local_llm.chat_completion_proxy import get_chat_completion from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages from letta.orm.user import User +from letta.otel.tracing import log_event, trace_method from letta.schemas.enums import ProviderCategory from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message @@ -35,7 +36,6 @@ from letta.schemas.provider_trace import ProviderTraceCreate from letta.services.telemetry_manager import TelemetryManager from letta.settings import ModelSettings from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface -from letta.tracing import log_event, trace_method LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq", "deepseek"] diff --git a/letta/llm_api/llm_client_base.py b/letta/llm_api/llm_client_base.py index e88b6081..81ab852b 100644 --- a/letta/llm_api/llm_client_base.py +++ b/letta/llm_api/llm_client_base.py @@ -1,3 +1,4 @@ +import json from abc import abstractmethod from typing import TYPE_CHECKING, Dict, List, Optional, Union @@ -6,13 +7,13 @@ from openai import AsyncStream, Stream from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from letta.errors import LLMError +from letta.otel.tracing import log_event, trace_method from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message from letta.schemas.openai.chat_completion_response import ChatCompletionResponse from letta.schemas.provider_trace import ProviderTraceCreate from letta.services.telemetry_manager import TelemetryManager -from letta.tracing import log_event, trace_method if TYPE_CHECKING: from letta.orm import User @@ -186,3 +187,30 @@ class LLMClientBase: An LLMError subclass that represents the error in a provider-agnostic way """ return LLMError(f"Unhandled LLM error: {str(e)}") + + def _fix_truncated_json_response(self, response: ChatCompletionResponse) -> ChatCompletionResponse: + """ + Fixes truncated JSON responses by ensuring the content is properly formatted. + This is a workaround for some providers that may return incomplete JSON. + """ + if response.choices and response.choices[0].message and response.choices[0].message.tool_calls: + tool_call_args_str = response.choices[0].message.tool_calls[0].function.arguments + try: + json.loads(tool_call_args_str) + except json.JSONDecodeError: + try: + json_str_end = "" + quote_count = tool_call_args_str.count('"') + if quote_count % 2 != 0: + json_str_end = json_str_end + '"' + + open_braces = tool_call_args_str.count("{") + close_braces = tool_call_args_str.count("}") + missing_braces = open_braces - close_braces + json_str_end += "}" * missing_braces + fixed_tool_call_args_str = tool_call_args_str[: -len(json_str_end)] + json_str_end + json.loads(fixed_tool_call_args_str) + response.choices[0].message.tool_calls[0].function.arguments = fixed_tool_call_args_str + except json.JSONDecodeError: + pass + return response diff --git a/letta/llm_api/openai.py b/letta/llm_api/openai.py index fe3b77cc..7372b1d0 100644 --- a/letta/llm_api/openai.py +++ b/letta/llm_api/openai.py @@ -19,6 +19,7 @@ from letta.llm_api.openai_client import ( from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages from letta.log import get_logger +from letta.otel.tracing import log_event from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as _Message from letta.schemas.message import MessageRole as _MessageRole @@ -36,7 +37,6 @@ from letta.schemas.openai.chat_completion_response import ( ) from letta.schemas.openai.embedding_response import EmbeddingResponse from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface -from letta.tracing import log_event from letta.utils import get_tool_call_id, smart_urljoin logger = get_logger(__name__) diff --git a/letta/llm_api/openai_client.py b/letta/llm_api/openai_client.py index ed77410a..ec289803 100644 --- a/letta/llm_api/openai_client.py +++ b/letta/llm_api/openai_client.py @@ -8,11 +8,11 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from letta.constants import LETTA_MODEL_ENDPOINT from letta.errors import ( + ContextWindowExceededError, ErrorCode, LLMAuthenticationError, LLMBadRequestError, LLMConnectionError, - LLMContextWindowExceededError, LLMNotFoundError, LLMPermissionDeniedError, LLMRateLimitError, @@ -23,6 +23,7 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions, convert_to_st from letta.llm_api.llm_client_base import LLMClientBase from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.enums import ProviderCategory, ProviderType from letta.schemas.llm_config import LLMConfig @@ -34,7 +35,6 @@ from letta.schemas.openai.chat_completion_request import Tool as OpenAITool from letta.schemas.openai.chat_completion_request import ToolFunctionChoice, cast_message_to_subtype from letta.schemas.openai.chat_completion_response import ChatCompletionResponse from letta.settings import model_settings -from letta.tracing import trace_method logger = get_logger(__name__) @@ -280,7 +280,7 @@ class OpenAIClient(LLMClientBase): # OpenAI's response structure directly maps to ChatCompletionResponse # We just need to instantiate the Pydantic model for validation and type safety. chat_completion_response = ChatCompletionResponse(**response_data) - + chat_completion_response = self._fix_truncated_json_response(chat_completion_response) # Unpack inner thoughts if they were embedded in function arguments if llm_config.put_inner_thoughts_in_kwargs: chat_completion_response = unpack_all_inner_thoughts_from_kwargs( @@ -342,11 +342,9 @@ class OpenAIClient(LLMClientBase): # Check message content if finer-grained errors are needed # Example: if "context_length_exceeded" in str(e): return LLMContextLengthExceededError(...) # TODO: This is a super soft check. Not sure if we can do better, needs more investigation. - if "context" in str(e): - return LLMContextWindowExceededError( - message=f"Bad request to OpenAI (context length exceeded): {str(e)}", - code=ErrorCode.INVALID_ARGUMENT, # Or more specific if detectable - details=e.body, + if "This model's maximum context length is" in str(e): + return ContextWindowExceededError( + message=f"Bad request to OpenAI (context window exceeded): {str(e)}", ) else: return LLMBadRequestError( diff --git a/letta/local_llm/chat_completion_proxy.py b/letta/local_llm/chat_completion_proxy.py index 35db97ed..214e0487 100644 --- a/letta/local_llm/chat_completion_proxy.py +++ b/letta/local_llm/chat_completion_proxy.py @@ -20,9 +20,9 @@ from letta.local_llm.utils import count_tokens, get_available_wrappers from letta.local_llm.vllm.api import get_vllm_completion from letta.local_llm.webui.api import get_webui_completion from letta.local_llm.webui.legacy_api import get_webui_completion as get_webui_completion_legacy +from letta.otel.tracing import log_event from letta.prompts.gpt_summarize import SYSTEM as SUMMARIZE_SYSTEM_MESSAGE from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, Message, ToolCall, UsageStatistics -from letta.tracing import log_event from letta.utils import get_tool_call_id has_shown_warning = False diff --git a/letta/memory.py b/letta/memory.py index 818f45ca..c6a03c14 100644 --- a/letta/memory.py +++ b/letta/memory.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List from letta.constants import MESSAGE_SUMMARY_REQUEST_ACK from letta.llm_api.llm_api_tools import create from letta.llm_api.llm_client import LLMClient +from letta.otel.tracing import trace_method from letta.prompts.gpt_summarize import SYSTEM as SUMMARY_PROMPT_SYSTEM from letta.schemas.agent import AgentState from letta.schemas.enums import MessageRole @@ -10,7 +11,6 @@ from letta.schemas.letta_message_content import TextContent from letta.schemas.memory import Memory from letta.schemas.message import Message from letta.settings import summarizer_settings -from letta.tracing import trace_method from letta.utils import count_tokens, printd if TYPE_CHECKING: diff --git a/letta/orm/enums.py b/letta/orm/enums.py index 926af3ea..d34ae687 100644 --- a/letta/orm/enums.py +++ b/letta/orm/enums.py @@ -9,6 +9,7 @@ class ToolType(str, Enum): LETTA_SLEEPTIME_CORE = "letta_sleeptime_core" LETTA_VOICE_SLEEPTIME_CORE = "letta_voice_sleeptime_core" LETTA_BUILTIN = "letta_builtin" + LETTA_FILES_CORE = "letta_files_core" EXTERNAL_COMPOSIO = "external_composio" EXTERNAL_LANGCHAIN = "external_langchain" # TODO is "external" the right name here? Since as of now, MCP is local / doesn't support remote? diff --git a/letta/orm/file.py b/letta/orm/file.py index baf14d26..2e8e5088 100644 --- a/letta/orm/file.py +++ b/letta/orm/file.py @@ -1,10 +1,13 @@ +import uuid from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import Integer, String +from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, desc +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import Mapped, mapped_column, relationship from letta.orm.mixins import OrganizationMixin, SourceMixin from letta.orm.sqlalchemy_base import SqlalchemyBase +from letta.schemas.enums import FileProcessingStatus from letta.schemas.file import FileMetadata as PydanticFileMetadata if TYPE_CHECKING: @@ -14,11 +17,36 @@ if TYPE_CHECKING: from letta.orm.source import Source -class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin): +# TODO: Note that this is NOT organization scoped, this is potentially dangerous if we misuse this +# TODO: This should ONLY be manipulated internally in relation to FileMetadata.content +# TODO: Leaving organization_id out of this for now for simplicity +class FileContent(SqlalchemyBase): + """Holds the full text content of a file (potentially large).""" + + __tablename__ = "file_contents" + __table_args__ = (UniqueConstraint("file_id", name="uq_file_contents_file_id"),) + + # TODO: We want to migrate all the ORM models to do this, so we will need to move this to the SqlalchemyBase + # TODO: Some still rely on the Pydantic object to do this + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"file_content-{uuid.uuid4()}") + file_id: Mapped[str] = mapped_column(ForeignKey("files.id", ondelete="CASCADE"), nullable=False, doc="Foreign key to files table.") + + text: Mapped[str] = mapped_column(Text, nullable=False, doc="Full plain-text content of the file (e.g., extracted from a PDF).") + + # back-reference to FileMetadata + file: Mapped["FileMetadata"] = relationship(back_populates="content", lazy="selectin") + + +class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin, AsyncAttrs): """Represents an uploaded file.""" __tablename__ = "files" __pydantic_model__ = PydanticFileMetadata + __table_args__ = ( + Index("ix_files_org_created", "organization_id", desc("created_at")), + Index("ix_files_source_created", "source_id", desc("created_at")), + Index("ix_files_processing_status", "processing_status"), + ) file_name: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The name of the file.") file_path: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The file path on the system.") @@ -26,6 +54,11 @@ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin): file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, doc="The size of the file in bytes.") file_creation_date: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The creation date of the file.") file_last_modified_date: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The last modified date of the file.") + processing_status: Mapped[FileProcessingStatus] = mapped_column( + String, default=FileProcessingStatus.PENDING, nullable=False, doc="The current processing status of the file." + ) + + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, doc="Any error message encountered during processing.") # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="files", lazy="selectin") @@ -33,4 +66,48 @@ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin): source_passages: Mapped[List["SourcePassage"]] = relationship( "SourcePassage", back_populates="file", lazy="selectin", cascade="all, delete-orphan" ) - file_agents: Mapped[List["FileAgent"]] = relationship("FileAgent", back_populates="file", lazy="selectin") + file_agents: Mapped[List["FileAgent"]] = relationship( + "FileAgent", + back_populates="file", + lazy="selectin", + cascade="all, delete-orphan", + passive_deletes=True, # ← add this + ) + content: Mapped[Optional["FileContent"]] = relationship( + "FileContent", + uselist=False, + back_populates="file", + lazy="raise", # raises if you access without eager load + cascade="all, delete-orphan", + ) + + async def to_pydantic_async(self, include_content: bool = False) -> PydanticFileMetadata: + """ + Async version of `to_pydantic` that supports optional relationship loading + without requiring `expire_on_commit=False`. + """ + + # Load content relationship if requested + if include_content: + content_obj = await self.awaitable_attrs.content + content_text = content_obj.text if content_obj else None + else: + content_text = None + + return PydanticFileMetadata( + id=self.id, + organization_id=self.organization_id, + source_id=self.source_id, + file_name=self.file_name, + file_path=self.file_path, + file_type=self.file_type, + file_size=self.file_size, + file_creation_date=self.file_creation_date, + file_last_modified_date=self.file_last_modified_date, + processing_status=self.processing_status, + error_message=self.error_message, + created_at=self.created_at, + updated_at=self.updated_at, + is_deleted=self.is_deleted, + content=content_text, + ) diff --git a/letta/orm/files_agents.py b/letta/orm/files_agents.py index 19b25bca..02856635 100644 --- a/letta/orm/files_agents.py +++ b/letta/orm/files_agents.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship +from letta.constants import CORE_MEMORY_SOURCE_CHAR_LIMIT, FILE_IS_TRUNCATED_WARNING from letta.orm.mixins import OrganizationMixin from letta.orm.sqlalchemy_base import SqlalchemyBase from letta.schemas.block import Block as PydanticBlock @@ -26,6 +27,8 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): __table_args__ = ( Index("ix_files_agents_file_id_agent_id", "file_id", "agent_id"), UniqueConstraint("file_id", "agent_id", name="uq_files_agents_file_agent"), + UniqueConstraint("agent_id", "file_name", name="uq_files_agents_agent_file_name"), + Index("ix_files_agents_agent_file_name", "agent_id", "file_name"), ) __pydantic_model__ = PydanticFileAgent @@ -33,6 +36,7 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): # TODO: Some still rely on the Pydantic object to do this id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"file_agent-{uuid.uuid4()}") file_id: Mapped[str] = mapped_column(String, ForeignKey("files.id", ondelete="CASCADE"), primary_key=True, doc="ID of the file.") + file_name: Mapped[str] = mapped_column(String, nullable=False, doc="Denormalized copy of files.file_name; unique per agent.") agent_id: Mapped[str] = mapped_column(String, ForeignKey("agents.id", ondelete="CASCADE"), primary_key=True, doc="ID of the agent.") is_open: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, doc="True if the agent currently has the file open.") @@ -55,11 +59,20 @@ class FileAgent(SqlalchemyBase, OrganizationMixin): "FileMetadata", foreign_keys=[file_id], lazy="selectin", + back_populates="file_agents", + passive_deletes=True, # ← add this ) # TODO: This is temporary as we figure out if we want FileBlock as a first class citizen def to_pydantic_block(self) -> PydanticBlock: visible_content = self.visible_content if self.visible_content and self.is_open else "" + + # Truncate content and add warnings here when converting from FileAgent to Block + if len(visible_content) > CORE_MEMORY_SOURCE_CHAR_LIMIT: + truncated_warning = f"...[TRUNCATED]\n{FILE_IS_TRUNCATED_WARNING}" + visible_content = visible_content[: CORE_MEMORY_SOURCE_CHAR_LIMIT - len(truncated_warning)] + visible_content += truncated_warning + return PydanticBlock( organization_id=self.organization_id, value=visible_content, diff --git a/letta/orm/sqlalchemy_base.py b/letta/orm/sqlalchemy_base.py index 0bc7bb70..944029bb 100644 --- a/letta/orm/sqlalchemy_base.py +++ b/letta/orm/sqlalchemy_base.py @@ -1,13 +1,15 @@ +import inspect from datetime import datetime from enum import Enum from functools import wraps from pprint import pformat from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union -from sqlalchemy import String, and_, delete, func, or_, select, text +from sqlalchemy import Sequence, String, and_, delete, func, or_, select, text from sqlalchemy.exc import DBAPIError, IntegrityError, TimeoutError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, Session, mapped_column +from sqlalchemy.orm.interfaces import ORMOption from letta.log import get_logger from letta.orm.base import Base, CommonSqlalchemyMetaMixins @@ -23,16 +25,28 @@ logger = get_logger(__name__) def handle_db_timeout(func): """Decorator to handle SQLAlchemy TimeoutError and wrap it in a custom exception.""" + if not inspect.iscoroutinefunction(func): - @wraps(func) - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except TimeoutError as e: - logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}") - raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e) + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except TimeoutError as e: + logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}") + raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e) - return wrapper + return wrapper + else: + + @wraps(func) + async def async_wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except TimeoutError as e: + logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}") + raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e) + + return async_wrapper class AccessType(str, Enum): @@ -163,6 +177,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base): join_conditions: Optional[Union[Tuple, List]] = None, identifier_keys: Optional[List[str]] = None, identity_id: Optional[str] = None, + query_options: Sequence[ORMOption] | None = None, # ← new **kwargs, ) -> List["SqlalchemyBase"]: """ @@ -224,6 +239,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base): identity_id=identity_id, **kwargs, ) + if query_options: + for opt in query_options: + query = query.options(opt) # Execute the query results = await db_session.execute(query) diff --git a/letta/otel/__init__.py b/letta/otel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/letta/otel/context.py b/letta/otel/context.py new file mode 100644 index 00000000..5e1aa5a4 --- /dev/null +++ b/letta/otel/context.py @@ -0,0 +1,25 @@ +from contextvars import ContextVar +from typing import Any, Dict + +# Create context var at module level (outside middleware) +request_attributes: ContextVar[Dict[str, Any]] = ContextVar("request_attributes", default={}) + + +# Helper functions +def set_ctx_attributes(attrs: Dict[str, Any]): + """Set attributes in current context""" + current = request_attributes.get() + new_attrs = {**current, **attrs} + request_attributes.set(new_attrs) + + +def add_ctx_attribute(key: str, value: Any): + """Add single attribute to current context""" + current = request_attributes.get() + new_attrs = {**current, key: value} + request_attributes.set(new_attrs) + + +def get_ctx_attributes() -> Dict[str, Any]: + """Get all attributes from current context""" + return request_attributes.get() diff --git a/letta/otel/events.py b/letta/otel/events.py new file mode 100644 index 00000000..e69de29b diff --git a/letta/otel/metric_registry.py b/letta/otel/metric_registry.py new file mode 100644 index 00000000..60ece713 --- /dev/null +++ b/letta/otel/metric_registry.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass, field +from functools import partial + +from opentelemetry import metrics +from opentelemetry.metrics import Counter, Histogram + +from letta.helpers.singleton import singleton +from letta.otel.metrics import get_letta_meter + + +@singleton +@dataclass(frozen=True) +class MetricRegistry: + """Registry of all application metrics + + Metrics are composed of the following: + - name + - description + - unit: UCUM unit of the metric (i.e. 'By' for bytes, 'ms' for milliseconds, '1' for count + - bucket_bounds (list[float] | None): the explicit bucket bounds for histogram metrics + + and instruments are of types Counter, Histogram, and Gauge + + The relationship between the various models is as follows: + project_id -N:1-> base_template_id -N:1-> template_id -N:1-> agent_id + agent_id -1:1+-> model_name + agent_id -1:N -> tool_name + """ + + Instrument = Counter | Histogram + _metrics: dict[str, Instrument] = field(default_factory=dict, init=False) + _meter: metrics.Meter = field(init=False) + + def __post_init__(self): + object.__setattr__(self, "_meter", get_letta_meter()) + + def _get_or_create_metric(self, name: str, factory): + """Lazy initialization of metrics.""" + if name not in self._metrics: + self._metrics[name] = factory() + return self._metrics[name] + + # (includes base attributes: project, template_base, template, agent) + @property + def user_message_counter(self) -> Counter: + return self._get_or_create_metric( + "count_user_message", + partial( + self._meter.create_counter, + name="count_user_message", + description="Counts the number of messages sent by the user", + unit="1", + ), + ) + + # (includes tool_name, tool_execution_success, & step_id on failure) + @property + def tool_execution_counter(self) -> Counter: + return self._get_or_create_metric( + "count_tool_execution", + partial(self._meter.create_counter, name="count_tool_execution", description="Counts the number of tools executed.", unit="1"), + ) + + # project_id + model + @property + def ttft_ms_histogram(self) -> Histogram: + return self._get_or_create_metric( + "hist_ttft_ms", + partial(self._meter.create_histogram, name="hist_ttft_ms", description="Histogram for the Time to First Token (ms)", unit="ms"), + ) + + # (includes model name) + @property + def llm_execution_time_ms_histogram(self) -> Histogram: + return self._get_or_create_metric( + "hist_llm_execution_time_ms", + partial( + self._meter.create_histogram, + name="hist_llm_execution_time_ms", + description="Histogram for LLM execution time (ms)", + unit="ms", + ), + ) + + # (includes tool name) + @property + def tool_execution_time_ms_histogram(self) -> Histogram: + return self._get_or_create_metric( + "hist_tool_execution_time_ms", + partial( + self._meter.create_histogram, + name="hist_tool_execution_time_ms", + description="Histogram for tool execution time (ms)", + unit="ms", + ), + ) + + # TODO (cliandy): instrument this + @property + def message_cost(self) -> Histogram: + return self._get_or_create_metric( + "hist_message_cost_usd", + partial( + self._meter.create_histogram, + name="hist_message_cost_usd", + description="Histogram for cost of messages (usd) per step", + unit="usd", + ), + ) + + # (includes model name) + @property + def message_output_tokens(self) -> Histogram: + return self._get_or_create_metric( + "hist_message_output_tokens", + partial( + self._meter.create_histogram, + name="hist_message_output_tokens", + description="Histogram for output tokens generated by LLM per step", + unit="1", + ), + ) diff --git a/letta/otel/metrics.py b/letta/otel/metrics.py new file mode 100644 index 00000000..8e11f291 --- /dev/null +++ b/letta/otel/metrics.py @@ -0,0 +1,66 @@ +from fastapi import FastAPI, Request +from opentelemetry import metrics +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter +from opentelemetry.metrics import NoOpMeter +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + +from letta.log import get_logger +from letta.otel.context import add_ctx_attribute +from letta.otel.resource import get_resource, is_pytest_environment + +logger = get_logger(__name__) + +_meter: metrics.Meter = NoOpMeter("noop") +_is_metrics_initialized: bool = False + + +async def _otel_metric_middleware(request: Request, call_next): + if not _is_metrics_initialized: + return await call_next(request) + + header_attributes = { + "x-organization-id": "organization.id", + "x-project-id": "project.id", + "x-base-template-id": "base_template.id", + "x-template-id": "template.id", + "x-agent-id": "agent.id", + } + try: + for header_key, otel_key in header_attributes.items(): + header_value = request.headers.get(header_key) + if header_value: + add_ctx_attribute(otel_key, header_value) + return await call_next(request) + except Exception: + raise + + +def setup_metrics( + endpoint: str, + app: FastAPI | None = None, + service_name: str = "memgpt-server", +) -> None: + if is_pytest_environment(): + return + assert endpoint + + global _is_metrics_initialized, _meter + + otlp_metric_exporter = OTLPMetricExporter(endpoint=endpoint) + metric_reader = PeriodicExportingMetricReader(exporter=otlp_metric_exporter) + meter_provider = MeterProvider(resource=get_resource(service_name), metric_readers=[metric_reader]) + metrics.set_meter_provider(meter_provider) + _meter = metrics.get_meter(__name__) + + if app: + app.middleware("http")(_otel_metric_middleware) + + _is_metrics_initialized = True + + +def get_letta_meter() -> metrics.Meter | None: + """Returns the global letta meter if metrics are initialized.""" + if not _is_metrics_initialized or isinstance(_meter, NoOpMeter): + logger.warning("Metrics are not initialized or meter is not available.") + return _meter diff --git a/letta/otel/resource.py b/letta/otel/resource.py new file mode 100644 index 00000000..d11e3104 --- /dev/null +++ b/letta/otel/resource.py @@ -0,0 +1,26 @@ +import os +import sys +import uuid + +from opentelemetry.sdk.resources import Resource + +from letta import __version__ as letta_version + +_resources = {} + + +def get_resource(service_name: str) -> Resource: + _env = os.getenv("LETTA_ENVIRONMENT") + if service_name not in _resources: + resource_dict = { + "service.name": service_name, + "letta.version": letta_version, + } + if _env != "PRODUCTION": + resource_dict["device.id"] = uuid.getnode() # MAC address as unique device identifier, + _resources[(service_name, _env)] = Resource.create(resource_dict) + return _resources[(service_name, _env)] + + +def is_pytest_environment(): + return "pytest" in sys.modules diff --git a/letta/tracing.py b/letta/otel/tracing.py similarity index 66% rename from letta/tracing.py rename to letta/otel/tracing.py index de3e4c5e..05cd485a 100644 --- a/letta/tracing.py +++ b/letta/otel/tracing.py @@ -1,6 +1,5 @@ import inspect import re -import sys import time from functools import wraps from typing import Any, Dict, List, Optional @@ -11,15 +10,18 @@ from fastapi.responses import JSONResponse from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.requests import RequestsInstrumentor -from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import Status, StatusCode -from letta import __version__ as letta_version +from letta.log import get_logger +from letta.otel.resource import get_resource, is_pytest_environment +from letta.settings import settings +logger = get_logger(__name__) # TODO: set up logger config for this tracer = trace.get_tracer(__name__) _is_tracing_initialized = False + _excluded_v1_endpoints_regex: List[str] = [ # "^GET /v1/agents/(?P[^/]+)/messages$", # "^GET /v1/agents/(?P[^/]+)/context$", @@ -30,11 +32,7 @@ _excluded_v1_endpoints_regex: List[str] = [ ] -def is_pytest_environment(): - return "pytest" in sys.modules - - -async def trace_request_middleware(request: Request, call_next): +async def _trace_request_middleware(request: Request, call_next): if not _is_tracing_initialized: return await call_next(request) initial_span_name = f"{request.method} {request.url.path}" @@ -56,7 +54,7 @@ async def trace_request_middleware(request: Request, call_next): raise -async def update_trace_attributes(request: Request): +async def _update_trace_attributes(request: Request): """Dependency to update trace attributes after FastAPI has processed the request""" if not _is_tracing_initialized: return @@ -78,35 +76,19 @@ async def update_trace_attributes(request: Request): for key, value in request.path_params.items(): span.set_attribute(f"http.{key}", value) - # Add user ID if available - user_id = request.headers.get("user_id") - if user_id: - span.set_attribute("user.id", user_id) - - # Add organization_id if available - organization_id = request.headers.get("x-organization-id") - if organization_id: - span.set_attribute("organization.id", organization_id) - - # Add project_id if available - project_id = request.headers.get("x-project-id") - if project_id: - span.set_attribute("project.id", project_id) - - # Add agent_id if available - agent_id = request.headers.get("x-agent-id") - if agent_id: - span.set_attribute("agent.id", agent_id) - - # Add template_id if available - template_id = request.headers.get("x-template-id") - if template_id: - span.set_attribute("template.id", template_id) - - # Add base_template_id if available - base_template_id = request.headers.get("x-base-template-id") - if base_template_id: - span.set_attribute("base_template.id", base_template_id) + # Add the following headers to span if available + header_attributes = { + "user_id": "user.id", + "x-organization-id": "organization.id", + "x-project-id": "project.id", + "x-agent-id": "agent.id", + "x-template-id": "template.id", + "x-base-template-id": "base_template.id", + } + for header_key, span_key in header_attributes.items(): + header_value = request.headers.get(header_key) + if header_value: + span.set_attribute(span_key, header_value) # Add request body if available try: @@ -117,7 +99,7 @@ async def update_trace_attributes(request: Request): pass -async def trace_error_handler(_request: Request, exc: Exception) -> JSONResponse: +async def _trace_error_handler(_request: Request, exc: Exception) -> JSONResponse: status_code = getattr(exc, "status_code", 500) error_msg = str(exc) @@ -142,49 +124,44 @@ def setup_tracing( ) -> None: if is_pytest_environment(): return + assert endpoint global _is_tracing_initialized - provider = TracerProvider(resource=Resource.create({"service.name": service_name})) - import uuid + tracer_provider = TracerProvider(resource=get_resource(service_name)) + tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) + _is_tracing_initialized = True + trace.set_tracer_provider(tracer_provider) - provider = TracerProvider( - resource=Resource.create( - { - "service.name": service_name, - "device.id": uuid.getnode(), # MAC address as unique device identifier, - "letta.version": letta_version, - } - ) - ) - if endpoint: - provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))) - _is_tracing_initialized = True - trace.set_tracer_provider(provider) + # Instrumentors (e.g., RequestsInstrumentor) + def requests_callback(span: trace.Span, _: Any, response: Any) -> None: + if hasattr(response, "status_code"): + span.set_status(Status(StatusCode.OK if response.status_code < 400 else StatusCode.ERROR)) - def requests_callback(span: trace.Span, _: Any, response: Any) -> None: - if hasattr(response, "status_code"): - span.set_status(Status(StatusCode.OK if response.status_code < 400 else StatusCode.ERROR)) + RequestsInstrumentor().instrument(response_hook=requests_callback) - RequestsInstrumentor().instrument(response_hook=requests_callback) + if settings.sqlalchemy_tracing: + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor - if app: - # Add middleware first - app.middleware("http")(trace_request_middleware) + SQLAlchemyInstrumentor().instrument() - # Add dependency to v1 routes - from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes + if app: + # Add middleware first + app.middleware("http")(_trace_request_middleware) - for router in v1_routes: - for route in router.routes: - full_path = ((next(iter(route.methods)) + " ") if route.methods else "") + "/v1" + route.path - if not any(re.match(regex, full_path) for regex in _excluded_v1_endpoints_regex): - route.dependencies.append(Depends(update_trace_attributes)) + # Add dependency to v1 routes + from letta.server.rest_api.routers.v1 import ROUTERS as V1_ROUTES - # Register exception handlers - app.exception_handler(HTTPException)(trace_error_handler) - app.exception_handler(RequestValidationError)(trace_error_handler) - app.exception_handler(Exception)(trace_error_handler) + for router in V1_ROUTES: + for route in router.routes: + full_path = ((next(iter(route.methods)) + " ") if route.methods else "") + "/v1" + route.path + if not any(re.match(regex, full_path) for regex in _excluded_v1_endpoints_regex): + route.dependencies.append(Depends(_update_trace_attributes)) + + # Register exception handlers for tracing + app.exception_handler(HTTPException)(_trace_error_handler) + app.exception_handler(RequestValidationError)(_trace_error_handler) + app.exception_handler(Exception)(_trace_error_handler) def trace_method(func): diff --git a/letta/plugins/README.md b/letta/plugins/README.md new file mode 100644 index 00000000..f43c427b --- /dev/null +++ b/letta/plugins/README.md @@ -0,0 +1,22 @@ +### Plugins + +Plugins enable plug and play for various components. + +Plugin configurations can be set in `letta.settings.settings`. + +The plugins will take a delimited list of consisting of individual plugin configs: + +`.=` + +joined by `;` + +In the default configuration, the top level keys have values `plugin_name`, +the `config_name` is nested under and the `class_or_function` is defined +after in format `:`. + +``` +DEFAULT_PLUGINS = { + "experimental_check": { + "default": "letta.plugins.defaults:is_experimental_enabled", + ... +``` diff --git a/letta/plugins/__init__.py b/letta/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/letta/plugins/defaults.py b/letta/plugins/defaults.py new file mode 100644 index 00000000..b22e1064 --- /dev/null +++ b/letta/plugins/defaults.py @@ -0,0 +1,11 @@ +from letta.settings import settings + + +def is_experimental_enabled(feature_name: str, **kwargs) -> bool: + if feature_name in ("async_agent_loop", "summarize"): + if not (kwargs.get("eligibility", False) and settings.use_experimental): + return False + return True + + # Err on safety here, disabling experimental if not handled here. + return False diff --git a/letta/plugins/plugins.py b/letta/plugins/plugins.py new file mode 100644 index 00000000..6530d205 --- /dev/null +++ b/letta/plugins/plugins.py @@ -0,0 +1,72 @@ +import importlib +from typing import Protocol, runtime_checkable + +from letta.settings import settings + + +@runtime_checkable +class SummarizerProtocol(Protocol): + """What a summarizer must implement""" + + async def summarize(self, text: str) -> str: ... + def get_name(self) -> str: ... + + +# Currently this supports one of each plugin type. This can be expanded in the future. +DEFAULT_PLUGINS = { + "experimental_check": { + "protocol": None, + "target": "letta.plugins.defaults:is_experimental_enabled", + }, + "summarizer": { + "protocol": SummarizerProtocol, + "target": "letta.services.summarizer.summarizer:Summarizer", + }, +} + + +def get_plugin(plugin_type: str): + """Get a plugin instance""" + plugin_register = dict(DEFAULT_PLUGINS, **settings.plugin_register_dict) + if plugin_type in plugin_register: + impl_path = plugin_register[plugin_type]["target"] + module_path, name = impl_path.split(":") + module = importlib.import_module(module_path) + plugin = getattr(module, name) + if type(plugin).__name__ == "function": + return plugin + elif type(plugin).__name__ == "class": + if plugin_register["protocol"] and not isinstance(plugin, type(plugin_register["protocol"])): + raise TypeError(f'{plugin} does not implement {type(plugin_register["protocol"]).__name__}') + return plugin() + raise TypeError("Unknown plugin type") + + +_experimental_checker = None +_summarizer = None + + +# TODO handle coroutines +# Convenience functions +def get_experimental_checker(): + global _experimental_checker + if _experimental_checker is None: + _experimental_checker = get_plugin("experimental_check") + return _experimental_checker + + +def get_summarizer(): + global _summarizer + if _summarizer is None: + _summarizer = get_plugin("summarizer") + return _summarizer + + +def reset_experimental_checker(): + global _experimental_checker + _experimental_checker = None + + +def reset_summarizer(): + global _summarizer + _summarizer = None diff --git a/letta/schemas/enums.py b/letta/schemas/enums.py index 555ffadd..c51bcbca 100644 --- a/letta/schemas/enums.py +++ b/letta/schemas/enums.py @@ -87,3 +87,11 @@ class ToolRuleType(str, Enum): constrain_child_tools = "constrain_child_tools" max_count_per_step = "max_count_per_step" parent_last_tool = "parent_last_tool" + + +class FileProcessingStatus(str, Enum): + PENDING = "pending" + PARSING = "parsing" + EMBEDDING = "embedding" + COMPLETED = "completed" + ERROR = "error" diff --git a/letta/schemas/file.py b/letta/schemas/file.py index ecd9fe0a..a4170b0a 100644 --- a/letta/schemas/file.py +++ b/letta/schemas/file.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import Field +from letta.schemas.enums import FileProcessingStatus from letta.schemas.letta_base import LettaBase @@ -34,12 +35,22 @@ class FileMetadata(FileMetadataBase): file_size: Optional[int] = Field(None, description="The size of the file in bytes.") file_creation_date: Optional[str] = Field(None, description="The creation date of the file.") file_last_modified_date: Optional[str] = Field(None, description="The last modified date of the file.") + processing_status: FileProcessingStatus = Field( + default=FileProcessingStatus.PENDING, + description="The current processing status of the file (e.g. pending, parsing, embedding, completed, error).", + ) + error_message: Optional[str] = Field(default=None, description="Optional error message if the file failed processing.") # orm metadata, optional fields created_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="The creation date of the file.") updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow, description="The update date of the file.") is_deleted: bool = Field(False, description="Whether this file is deleted or not.") + # This is optional, and only occasionally pulled in since it can be very large + content: Optional[str] = Field( + default=None, description="Optional full-text content of the file; only populated on demand due to its size." + ) + class FileAgentBase(LettaBase): """Base class for the FileMetadata-⇄-Agent association schemas""" @@ -67,6 +78,7 @@ class FileAgent(FileAgentBase): ) agent_id: str = Field(..., description="Unique identifier of the agent.") file_id: str = Field(..., description="Unique identifier of the file.") + file_name: str = Field(..., description="Name of the file.") is_open: bool = Field(True, description="True if the agent currently has the file open.") visible_content: Optional[str] = Field( None, diff --git a/letta/schemas/tool.py b/letta/schemas/tool.py index d805f6ec..e7b23e86 100644 --- a/letta/schemas/tool.py +++ b/letta/schemas/tool.py @@ -7,6 +7,7 @@ from letta.constants import ( FUNCTION_RETURN_CHAR_LIMIT, LETTA_BUILTIN_TOOL_MODULE_NAME, LETTA_CORE_TOOL_MODULE_NAME, + LETTA_FILES_TOOL_MODULE_NAME, LETTA_MULTI_AGENT_TOOL_MODULE_NAME, LETTA_VOICE_TOOL_MODULE_NAME, MCP_TOOL_TAG_NAME_PREFIX, @@ -106,6 +107,9 @@ class Tool(BaseTool): elif self.tool_type in {ToolType.LETTA_BUILTIN}: # If it's letta voice tool, we generate the json_schema on the fly here self.json_schema = get_json_schema_from_module(module_name=LETTA_BUILTIN_TOOL_MODULE_NAME, function_name=self.name) + elif self.tool_type in {ToolType.LETTA_FILES_CORE}: + # If it's letta files tool, we generate the json_schema on the fly here + self.json_schema = get_json_schema_from_module(module_name=LETTA_FILES_TOOL_MODULE_NAME, function_name=self.name) elif self.tool_type in {ToolType.EXTERNAL_COMPOSIO}: # Composio schemas handled separately pass diff --git a/letta/server/db.py b/letta/server/db.py index 5816b6dc..cf423c59 100644 --- a/letta/server/db.py +++ b/letta/server/db.py @@ -13,8 +13,8 @@ from sqlalchemy.orm import sessionmaker from letta.config import LettaConfig from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.settings import settings -from letta.tracing import trace_method logger = get_logger(__name__) @@ -131,7 +131,12 @@ class DatabaseRegistry: # Create async session factory self._async_engines["default"] = async_engine self._async_session_factories["default"] = async_sessionmaker( - close_resets_only=False, autocommit=False, autoflush=False, bind=self._async_engines["default"], class_=AsyncSession + expire_on_commit=True, + close_resets_only=False, + autocommit=False, + autoflush=False, + bind=self._async_engines["default"], + class_=AsyncSession, ) self._initialized["async"] = True @@ -207,11 +212,6 @@ class DatabaseRegistry: self.initialize_sync() return self._engines.get(name) - def get_async_engine(self, name: str = "default") -> AsyncEngine: - """Get an async database engine by name.""" - self.initialize_async() - return self._async_engines.get(name) - def get_session_factory(self, name: str = "default") -> sessionmaker: """Get a session factory by name.""" self.initialize_sync() diff --git a/letta/server/rest_api/app.py b/letta/server/rest_api/app.py index 944fc2bd..7c249087 100644 --- a/letta/server/rest_api/app.py +++ b/letta/server/rest_api/app.py @@ -256,13 +256,15 @@ def create_application() -> "FastAPI": print(f"▶ Using OTLP tracing with endpoint: {otlp_endpoint}") env_name_suffix = os.getenv("ENV_NAME") service_name = f"letta-server-{env_name_suffix.lower()}" if env_name_suffix else "letta-server" - from letta.tracing import setup_tracing + from letta.otel.metrics import setup_metrics + from letta.otel.tracing import setup_tracing setup_tracing( endpoint=otlp_endpoint, app=app, service_name=service_name, ) + setup_metrics(endpoint=otlp_endpoint, app=app, service_name=service_name) for route in v1_routes: app.include_router(route, prefix=API_PREFIX) @@ -331,7 +333,7 @@ def start_server( if (os.getenv("LOCAL_HTTPS") == "true") or "--localhttps" in sys.argv: print(f"▶ Server running at: https://{host or 'localhost'}:{port or REST_DEFAULT_PORT}") print(f"▶ View using ADE at: https://app.letta.com/development-servers/local/dashboard\n") - if importlib.util.find_spec("granian") is not None and settings.use_uvloop: + if importlib.util.find_spec("granian") is not None and settings.use_granian: from granian import Granian # Experimental Granian engine @@ -339,14 +341,14 @@ def start_server( target="letta.server.rest_api.app:app", # factory=True, interface="asgi", - address=host or "localhost", + address=host or "127.0.0.1", # Note granian address must be an ip address port=port or REST_DEFAULT_PORT, workers=settings.uvicorn_workers, # threads= reload=reload or settings.uvicorn_reload, reload_ignore_patterns=["openapi_letta.json"], reload_ignore_worker_failure=True, - reload_tick=100, + reload_tick=4000, # set to 4s to prevent crashing on weird state # log_level="info" ssl_keyfile="certs/localhost-key.pem", ssl_cert="certs/localhost.pem", @@ -380,14 +382,14 @@ def start_server( target="letta.server.rest_api.app:app", # factory=True, interface="asgi", - address=host or "localhost", + address=host or "127.0.0.1", # Note granian address must be an ip address port=port or REST_DEFAULT_PORT, workers=settings.uvicorn_workers, # threads= reload=reload or settings.uvicorn_reload, reload_ignore_patterns=["openapi_letta.json"], reload_ignore_worker_failure=True, - reload_tick=100, + reload_tick=4000, # set to 4s to prevent crashing on weird state # log_level="info" ).serve() else: diff --git a/letta/server/rest_api/routers/v1/agents.py b/letta/server/rest_api/routers/v1/agents.py index f13aaa3e..4fc29bad 100644 --- a/letta/server/rest_api/routers/v1/agents.py +++ b/letta/server/rest_api/routers/v1/agents.py @@ -12,11 +12,13 @@ from sqlalchemy.exc import IntegrityError, OperationalError from starlette.responses import Response, StreamingResponse from letta.agents.letta_agent import LettaAgent -from letta.constants import CORE_MEMORY_SOURCE_CHAR_LIMIT, DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG +from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG from letta.groups.sleeptime_multi_agent_v2 import SleeptimeMultiAgentV2 from letta.helpers.datetime_helpers import get_utc_timestamp_ns from letta.log import get_logger from letta.orm.errors import NoResultFound +from letta.otel.context import get_ctx_attributes +from letta.otel.metric_registry import MetricRegistry from letta.schemas.agent import AgentState, AgentType, CreateAgent, UpdateAgent from letta.schemas.block import Block, BlockUpdate from letta.schemas.group import Group @@ -149,7 +151,7 @@ def export_agent_serialized( @router.post("/import", response_model=AgentState, operation_id="import_agent_serialized") -async def import_agent_serialized( +def import_agent_serialized( file: UploadFile = File(...), server: "SyncServer" = Depends(get_letta_server), actor_id: Optional[str] = Header(None, alias="user_id"), @@ -167,10 +169,10 @@ async def import_agent_serialized( """ Import a serialized agent file and recreate the agent in the system. """ - actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) + actor = server.user_manager.get_user_or_default(user_id=actor_id) try: - serialized_data = await file.read() + serialized_data = file.file.read() agent_json = json.loads(serialized_data) # Validate the JSON against AgentSchema before passing it to deserialize @@ -311,20 +313,21 @@ async def attach_source( actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) agent_state = await server.agent_manager.attach_source_async(agent_id=agent_id, source_id=source_id, actor=actor) - files = await server.source_manager.list_files(source_id, actor) + # Check if the agent is missing any files tools + agent_state = await server.agent_manager.attach_missing_files_tools_async(agent_state=agent_state, actor=actor) + + files = await server.source_manager.list_files(source_id, actor, include_content=True) texts = [] file_ids = [] + file_names = [] for f in files: - passages = await server.passage_manager.list_passages_by_file_id_async(file_id=f.id, actor=actor) - passage_text = "" - for p in passages: - if len(passage_text) <= CORE_MEMORY_SOURCE_CHAR_LIMIT: - passage_text += p.text - - texts.append(passage_text) + texts.append(f.content if f.content else "") file_ids.append(f.id) + file_names.append(f.file_name) - await server.insert_files_into_context_window(agent_state=agent_state, texts=texts, file_ids=file_ids, actor=actor) + await server.insert_files_into_context_window( + agent_state=agent_state, texts=texts, file_ids=file_ids, file_names=file_names, actor=actor + ) if agent_state.enable_sleeptime: source = await server.source_manager.get_source_by_id(source_id=source_id) @@ -347,6 +350,10 @@ async def detach_source( """ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) agent_state = await server.agent_manager.detach_source_async(agent_id=agent_id, source_id=source_id, actor=actor) + + if not agent_state.sources: + agent_state = await server.agent_manager.detach_all_files_tools_async(agent_state=agent_state, actor=actor) + files = await server.source_manager.list_files(source_id, actor) file_ids = [f.id for f in files] await server.remove_files_from_context_window(agent_state=agent_state, file_ids=file_ids, actor=actor) @@ -451,7 +458,7 @@ async def list_blocks( """ Retrieve the core memory blocks of a specific agent. """ - actor = server.user_manager.get_user_or_default(user_id=actor_id) + actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) try: agent = await server.agent_manager.get_agent_by_id_async(agent_id=agent_id, include_relationships=["memory"], actor=actor) return agent.memory.blocks @@ -658,19 +665,18 @@ async def send_message( Process a user message and return the agent's response. This endpoint accepts a message from a user and processes it through the agent. """ + MetricRegistry().user_message_counter.add(1, get_ctx_attributes()) + actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) request_start_timestamp_ns = get_utc_timestamp_ns() - user_eligible = True # TODO: This is redundant, remove soon agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"]) agent_eligible = agent.enable_sleeptime or agent.agent_type == AgentType.sleeptime_agent or not agent.multi_agent_group - experimental_header = request_obj.headers.get("X-EXPERIMENTAL") or "false" - feature_enabled = settings.use_experimental or experimental_header.lower() == "true" model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex"] - if user_eligible and agent_eligible and feature_enabled and model_compatible: + if agent_eligible and model_compatible: if agent.enable_sleeptime and agent.agent_type != AgentType.voice_convo_agent: - experimental_agent = SleeptimeMultiAgentV2( + agent_loop = SleeptimeMultiAgentV2( agent_id=agent_id, message_manager=server.message_manager, agent_manager=server.agent_manager, @@ -682,7 +688,7 @@ async def send_message( group=agent.multi_agent_group, ) else: - experimental_agent = LettaAgent( + agent_loop = LettaAgent( agent_id=agent_id, message_manager=server.message_manager, agent_manager=server.agent_manager, @@ -693,7 +699,7 @@ async def send_message( telemetry_manager=server.telemetry_manager if settings.llm_api_logging else NoopTelemetryManager(), ) - result = await experimental_agent.step( + result = await agent_loop.step( request.messages, max_steps=10, use_assistant_message=request.use_assistant_message, @@ -739,22 +745,20 @@ async def send_message_streaming( This endpoint accepts a message from a user and processes it through the agent. It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True. """ - request_start_timestamp_ns = get_utc_timestamp_ns() + MetricRegistry().user_message_counter.add(1, get_ctx_attributes()) + actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) - user_eligible = actor.organization_id not in ["org-4a3af5dd-4c6a-48cb-ac13-3f73ecaaa4bf", "org-4ab3f6e8-9a44-4bee-aeb6-c681cbbc7bf6"] # TODO: This is redundant, remove soon agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"]) agent_eligible = agent.enable_sleeptime or agent.agent_type == AgentType.sleeptime_agent or not agent.multi_agent_group - experimental_header = request_obj.headers.get("X-EXPERIMENTAL") or "false" - feature_enabled = settings.use_experimental or experimental_header.lower() == "true" model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex"] model_compatible_token_streaming = agent.llm_config.model_endpoint_type in ["anthropic", "openai"] not_letta_endpoint = not ("inference.letta.com" in agent.llm_config.model_endpoint) request_start_timestamp_ns = get_utc_timestamp_ns() - if user_eligible and agent_eligible and feature_enabled and model_compatible: + if agent_eligible and model_compatible: if agent.enable_sleeptime and agent.agent_type != AgentType.voice_convo_agent: - experimental_agent = SleeptimeMultiAgentV2( + agent_loop = SleeptimeMultiAgentV2( agent_id=agent_id, message_manager=server.message_manager, agent_manager=server.agent_manager, @@ -768,7 +772,7 @@ async def send_message_streaming( group=agent.multi_agent_group, ) else: - experimental_agent = LettaAgent( + agent_loop = LettaAgent( agent_id=agent_id, message_manager=server.message_manager, agent_manager=server.agent_manager, @@ -782,7 +786,7 @@ async def send_message_streaming( if request.stream_tokens and model_compatible_token_streaming and not_letta_endpoint: result = StreamingResponseWithStatusCode( - experimental_agent.step_stream( + agent_loop.step_stream( input_messages=request.messages, max_steps=10, use_assistant_message=request.use_assistant_message, @@ -792,7 +796,7 @@ async def send_message_streaming( ) else: result = StreamingResponseWithStatusCode( - experimental_agent.step_stream_no_tokens( + agent_loop.step_stream_no_tokens( request.messages, max_steps=10, use_assistant_message=request.use_assistant_message, @@ -878,6 +882,7 @@ async def send_message_async( Asynchronously process a user message and return a run object. The actual processing happens in the background, and the status can be checked using the run ID. """ + MetricRegistry().user_message_counter.add(1, get_ctx_attributes()) actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) # Create a new job @@ -953,17 +958,13 @@ async def summarize_agent_conversation( This endpoint summarizes the current message history for a given agent, truncating and compressing it down to the specified `max_message_length`. """ - actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) - # user_eligible = actor.organization_id not in ["org-4a3af5dd-4c6a-48cb-ac13-3f73ecaaa4bf", "org-4ab3f6e8-9a44-4bee-aeb6-c681cbbc7bf6"] - # TODO: This is redundant, remove soon + actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor, include_relationships=["multi_agent_group"]) agent_eligible = agent.enable_sleeptime or agent.agent_type == AgentType.sleeptime_agent or not agent.multi_agent_group - experimental_header = request_obj.headers.get("X-EXPERIMENTAL") or "false" - feature_enabled = settings.use_experimental or experimental_header.lower() == "true" model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai", "together", "google_ai", "google_vertex"] - if agent_eligible and feature_enabled and model_compatible: + if agent_eligible and model_compatible: agent = LettaAgent( agent_id=agent_id, message_manager=server.message_manager, diff --git a/letta/server/rest_api/routers/v1/groups.py b/letta/server/rest_api/routers/v1/groups.py index 25049536..559b98d5 100644 --- a/letta/server/rest_api/routers/v1/groups.py +++ b/letta/server/rest_api/routers/v1/groups.py @@ -86,7 +86,7 @@ def create_group( @router.patch("/{group_id}", response_model=Group, operation_id="modify_group") -def modify_group( +async def modify_group( group_id: str, group: GroupUpdate = Body(...), server: "SyncServer" = Depends(get_letta_server), @@ -97,8 +97,8 @@ def modify_group( Create a new multi-agent group with the specified configuration. """ try: - actor = server.user_manager.get_user_or_default(user_id=actor_id) - return server.group_manager.modify_group(group_id=group_id, group_update=group, actor=actor) + actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) + return await server.group_manager.modify_group_async(group_id=group_id, group_update=group, actor=actor) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/letta/server/rest_api/routers/v1/sources.py b/letta/server/rest_api/routers/v1/sources.py index cc57d977..2cdf9090 100644 --- a/letta/server/rest_api/routers/v1/sources.py +++ b/letta/server/rest_api/routers/v1/sources.py @@ -27,6 +27,11 @@ from letta.utils import safe_create_task, sanitize_filename logger = get_logger(__name__) +mimetypes.add_type("text/markdown", ".md") +mimetypes.add_type("text/markdown", ".markdown") +mimetypes.add_type("application/jsonl", ".jsonl") +mimetypes.add_type("application/x-jsonlines", ".jsonl") + router = APIRouter(prefix="/sources", tags=["sources"]) @@ -174,7 +179,15 @@ async def upload_file_to_source( """ Upload a file to a data source. """ - allowed_media_types = {"application/pdf", "text/plain", "application/json"} + allowed_media_types = { + "application/pdf", + "text/plain", + "text/markdown", + "text/x-markdown", + "application/json", + "application/jsonl", + "application/x-jsonlines", + } # Normalize incoming Content-Type header (strip charset or any parameters). raw_ct = file.content_type or "" @@ -192,6 +205,9 @@ async def upload_file_to_source( ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".jsonl": "application/jsonl", } media_type = ext_map.get(ext, media_type) @@ -270,14 +286,21 @@ async def list_source_files( source_id: str, limit: int = Query(1000, description="Number of files to return"), after: Optional[str] = Query(None, description="Pagination cursor to fetch the next set of results"), + include_content: bool = Query(False, description="Whether to include full file content"), server: "SyncServer" = Depends(get_letta_server), - actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present + actor_id: Optional[str] = Header(None, alias="user_id"), ): """ List paginated files associated with a data source. """ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id) - return await server.source_manager.list_files(source_id=source_id, limit=limit, after=after, actor=actor) + return await server.source_manager.list_files( + source_id=source_id, + limit=limit, + after=after, + actor=actor, + include_content=include_content, + ) # it's redundant to include /delete in the URL path. The HTTP verb DELETE already implies that action. diff --git a/letta/server/rest_api/utils.py b/letta/server/rest_api/utils.py index d12b100f..f5ba0cbd 100644 --- a/letta/server/rest_api/utils.py +++ b/letta/server/rest_api/utils.py @@ -15,9 +15,12 @@ from pydantic import BaseModel from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG, FUNC_FAILED_HEARTBEAT_MESSAGE, REQ_HEARTBEAT_MESSAGE from letta.errors import ContextWindowExceededError, RateLimitExceededError -from letta.helpers.datetime_helpers import get_utc_time, get_utc_timestamp_ns +from letta.helpers.datetime_helpers import get_utc_time, get_utc_timestamp_ns, ns_to_ms from letta.helpers.message_helper import convert_message_creates_to_messages from letta.log import get_logger +from letta.otel.context import get_ctx_attributes +from letta.otel.metric_registry import MetricRegistry +from letta.otel.tracing import tracer from letta.schemas.enums import MessageRole from letta.schemas.letta_message_content import OmittedReasoningContent, ReasoningContent, RedactedReasoningContent, TextContent from letta.schemas.llm_config import LLMConfig @@ -27,7 +30,6 @@ from letta.schemas.usage import LettaUsageStatistics from letta.schemas.user import User from letta.server.rest_api.interface import StreamingServerInterface from letta.system import get_heartbeat, package_function_response -from letta.tracing import tracer if TYPE_CHECKING: from letta.server.server import SyncServer @@ -81,8 +83,12 @@ async def sse_async_generator( if first_chunk and ttft_span is not None: now = get_utc_timestamp_ns() ttft_ns = now - request_start_timestamp_ns - ttft_span.add_event(name="time_to_first_token_ms", attributes={"ttft_ms": ttft_ns // 1_000_000}) + ttft_span.add_event(name="time_to_first_token_ms", attributes={"ttft_ms": ns_to_ms(ttft_ns)}) ttft_span.end() + metric_attributes = get_ctx_attributes() + if llm_config: + metric_attributes["model.name"] = llm_config.model + MetricRegistry().ttft_ms_histogram.record(ns_to_ms(ttft_ns), metric_attributes) first_chunk = False # yield f"data: {json.dumps(chunk)}\n\n" @@ -190,7 +196,6 @@ def create_letta_messages_from_llm_response( add_heartbeat_request_system_message: bool = False, reasoning_content: Optional[List[Union[TextContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent]]] = None, pre_computed_assistant_message_id: Optional[str] = None, - pre_computed_tool_message_id: Optional[str] = None, llm_batch_item_id: Optional[str] = None, step_id: str | None = None, ) -> List[Message]: @@ -245,8 +250,6 @@ def create_letta_messages_from_llm_response( ) ], ) - if pre_computed_tool_message_id: - tool_message.id = pre_computed_tool_message_id messages.append(tool_message) if add_heartbeat_request_system_message: diff --git a/letta/server/server.py b/letta/server/server.py index b83a70ce..3b417542 100644 --- a/letta/server/server.py +++ b/letta/server/server.py @@ -21,7 +21,7 @@ import letta.system as system from letta.agent import Agent, save_agent from letta.agents.letta_agent import LettaAgent from letta.config import LettaConfig -from letta.constants import CORE_MEMORY_SOURCE_CHAR_LIMIT, LETTA_TOOL_EXECUTION_DIR +from letta.constants import LETTA_TOOL_EXECUTION_DIR from letta.data_sources.connectors import DataConnector, load_data from letta.errors import HandleNotFoundError from letta.functions.mcp_client.types import MCPServerType, MCPTool, SSEServerConfig, StdioServerConfig @@ -34,6 +34,7 @@ from letta.interface import AgentInterface # abstract from letta.interface import CLIInterface # for printing to terminal from letta.log import get_logger from letta.orm.errors import NoResultFound +from letta.otel.tracing import log_event, trace_method from letta.prompts.gpt_system import get_system_text from letta.schemas.agent import AgentState, AgentType, CreateAgent, UpdateAgent from letta.schemas.block import Block, BlockUpdate, CreateBlock @@ -101,7 +102,6 @@ from letta.services.tool_executor.tool_execution_manager import ToolExecutionMan from letta.services.tool_manager import ToolManager from letta.services.user_manager import UserManager from letta.settings import model_settings, settings, tool_settings -from letta.tracing import log_event, trace_method from letta.utils import get_friendly_error_msg, get_persona_text, make_key config = LettaConfig.load() @@ -1108,13 +1108,11 @@ class SyncServer(Server): after: Optional[str] = None, before: Optional[str] = None, limit: Optional[int] = 100, - order_by: Optional[str] = "created_at", - reverse: Optional[bool] = False, query_text: Optional[str] = None, ascending: Optional[bool] = True, ) -> List[Passage]: # iterate over records - records = await self.agent_manager.list_passages_async( + records = await self.agent_manager.list_agent_passages_async( actor=actor, agent_id=agent_id, after=after, @@ -1368,12 +1366,13 @@ class SyncServer(Server): ) await self.agent_manager.delete_agent_async(agent_id=sleeptime_agent_state.id, actor=actor) - async def _upsert_file_to_agent(self, agent_id: str, text: str, file_id: str, actor: User) -> None: + async def _upsert_file_to_agent(self, agent_id: str, text: str, file_id: str, file_name: str, actor: User) -> None: """ Internal method to create or update a file <-> agent association """ - truncated_text = text[:CORE_MEMORY_SOURCE_CHAR_LIMIT] - await self.file_agent_manager.attach_file(agent_id=agent_id, file_id=file_id, actor=actor, visible_content=truncated_text) + await self.file_agent_manager.attach_file( + agent_id=agent_id, file_id=file_id, file_name=file_name, actor=actor, visible_content=text + ) async def _remove_file_from_agent(self, agent_id: str, file_id: str, actor: User) -> None: """ @@ -1389,7 +1388,7 @@ class SyncServer(Server): logger.info(f"File {file_id} already removed from agent {agent_id}, skipping...") async def insert_file_into_context_windows( - self, source_id: str, text: str, file_id: str, actor: User, agent_states: Optional[List[AgentState]] = None + self, source_id: str, text: str, file_id: str, file_name: str, actor: User, agent_states: Optional[List[AgentState]] = None ) -> List[AgentState]: """ Insert the uploaded document into the context window of all agents @@ -1404,11 +1403,13 @@ class SyncServer(Server): logger.info(f"Inserting document into context window for source: {source_id}") logger.info(f"Attached agents: {[a.id for a in agent_states]}") - await asyncio.gather(*(self._upsert_file_to_agent(agent_state.id, text, file_id, actor) for agent_state in agent_states)) + await asyncio.gather(*(self._upsert_file_to_agent(agent_state.id, text, file_id, file_name, actor) for agent_state in agent_states)) return agent_states - async def insert_files_into_context_window(self, agent_state: AgentState, texts: List[str], file_ids: List[str], actor: User) -> None: + async def insert_files_into_context_window( + self, agent_state: AgentState, texts: List[str], file_ids: List[str], file_names: List[str], actor: User + ) -> None: """ Insert the uploaded documents into the context window of an agent attached to the given source. @@ -1418,7 +1419,12 @@ class SyncServer(Server): if len(texts) != len(file_ids): raise ValueError(f"Mismatch between number of texts ({len(texts)}) and file ids ({len(file_ids)})") - await asyncio.gather(*(self._upsert_file_to_agent(agent_state.id, text, file_id, actor) for text, file_id in zip(texts, file_ids))) + await asyncio.gather( + *( + self._upsert_file_to_agent(agent_state.id, text, file_id, file_name, actor) + for text, file_id, file_name in zip(texts, file_ids, file_names) + ) + ) async def remove_file_from_context_windows(self, source_id: str, file_id: str, actor: User) -> None: """ diff --git a/letta/services/agent_manager.py b/letta/services/agent_manager.py index 92ad1681..36a7919f 100644 --- a/letta/services/agent_manager.py +++ b/letta/services/agent_manager.py @@ -3,9 +3,8 @@ import os from datetime import datetime, timezone from typing import Dict, List, Optional, Set, Tuple -import numpy as np import sqlalchemy as sa -from sqlalchemy import Select, and_, delete, func, insert, literal, or_, select, union_all +from sqlalchemy import delete, func, insert, literal, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert from letta.constants import ( @@ -17,10 +16,9 @@ from letta.constants import ( BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, DATA_SOURCE_ATTACH_ALERT, - MAX_EMBEDDING_DIM, + FILES_TOOLS, MULTI_AGENT_TOOLS, ) -from letta.embeddings import embedding_model from letta.helpers.datetime_helpers import get_utc_time from letta.llm_api.llm_client import LLMClient from letta.log import get_logger @@ -39,7 +37,7 @@ from letta.orm.errors import NoResultFound from letta.orm.sandbox_config import AgentEnvironmentVariable from letta.orm.sandbox_config import AgentEnvironmentVariable as AgentEnvironmentVariableModel from letta.orm.sqlalchemy_base import AccessType -from letta.orm.sqlite_functions import adapt_array +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState as PydanticAgentState from letta.schemas.agent import AgentType, CreateAgent, UpdateAgent, get_prompt_template_for_agent_type from letta.schemas.block import DEFAULT_BLOCKS @@ -66,6 +64,7 @@ from letta.server.db import db_registry from letta.services.block_manager import BlockManager from letta.services.context_window_calculator.context_window_calculator import ContextWindowCalculator from letta.services.context_window_calculator.token_counter import AnthropicTokenCounter, TiktokenCounter +from letta.services.files_agents_manager import FileAgentManager from letta.services.helpers.agent_manager_helper import ( _apply_filters, _apply_identity_filters, @@ -74,6 +73,9 @@ from letta.services.helpers.agent_manager_helper import ( _apply_tag_filter, _process_relationship, _process_relationship_async, + build_agent_passage_query, + build_passage_query, + build_source_passage_query, check_supports_structured_output, compile_system_message, derive_system_message, @@ -85,8 +87,6 @@ from letta.services.message_manager import MessageManager from letta.services.passage_manager import PassageManager from letta.services.source_manager import SourceManager from letta.services.tool_manager import ToolManager -from letta.settings import settings -from letta.tracing import trace_method from letta.utils import enforce_types, united_diff logger = get_logger(__name__) @@ -102,6 +102,7 @@ class AgentManager: self.message_manager = MessageManager() self.passage_manager = PassageManager() self.identity_manager = IdentityManager() + self.file_agent_manager = FileAgentManager() @staticmethod def _resolve_tools(session, names: Set[str], ids: Set[str], org_id: str) -> Tuple[Dict[str, str], Dict[str, str]]: @@ -1384,6 +1385,11 @@ class AgentManager: curr_system_message = self.get_system_message( agent_id=agent_id, actor=actor ) # this is the system + memory bank, not just the system prompt + + if curr_system_message is None: + logger.warning(f"No system message found for agent {agent_state.id} and user {actor}") + return agent_state + curr_system_message_openai = curr_system_message.to_openai_dict() # note: we only update the system prompt if the core memory is changed @@ -1451,6 +1457,11 @@ class AgentManager: curr_system_message = await self.get_system_message_async( agent_id=agent_id, actor=actor ) # this is the system + memory bank, not just the system prompt + + if curr_system_message is None: + logger.warning(f"No system message found for agent {agent_state.id} and user {actor}") + return agent_state + curr_system_message_openai = curr_system_message.to_openai_dict() # note: we only update the system prompt if the core memory is changed @@ -1650,12 +1661,18 @@ class AgentManager: @trace_method @enforce_types async def refresh_memory_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState: + # TODO: This will NOT work for new blocks/file blocks added intra-step block_ids = [b.id for b in agent_state.memory.blocks] - if not block_ids: - return agent_state + file_block_names = [b.label for b in agent_state.memory.file_blocks] + + if block_ids: + blocks = await self.block_manager.get_all_blocks_by_ids_async(block_ids=[b.id for b in agent_state.memory.blocks], actor=actor) + agent_state.memory.blocks = [b for b in blocks if b is not None] + + if file_block_names: + file_blocks = await self.file_agent_manager.get_all_file_blocks_by_name(file_names=file_block_names, actor=actor) + agent_state.memory.file_blocks = [b for b in file_blocks if b is not None] - blocks = await self.block_manager.get_all_blocks_by_ids_async(block_ids=[b.id for b in agent_state.memory.blocks], actor=actor) - agent_state.memory.blocks = [b for b in blocks if b is not None] return agent_state # ====================================================================================================================== @@ -2006,184 +2023,6 @@ class AgentManager: # ====================================================================================================================== # Passage Management # ====================================================================================================================== - def _build_passage_query( - self, - actor: PydanticUser, - agent_id: Optional[str] = None, - file_id: Optional[str] = None, - query_text: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - before: Optional[str] = None, - after: Optional[str] = None, - source_id: Optional[str] = None, - embed_query: bool = False, - ascending: bool = True, - embedding_config: Optional[EmbeddingConfig] = None, - agent_only: bool = False, - ) -> Select: - """Helper function to build the base passage query with all filters applied. - Supports both before and after pagination across merged source and agent passages. - - Returns the query before any limit or count operations are applied. - """ - embedded_text = None - if embed_query: - assert embedding_config is not None, "embedding_config must be specified for vector search" - assert query_text is not None, "query_text must be specified for vector search" - embedded_text = embedding_model(embedding_config).get_text_embedding(query_text) - embedded_text = np.array(embedded_text) - embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist() - - # Start with base query for source passages - source_passages = None - if not agent_only: # Include source passages - if agent_id is not None: - source_passages = ( - select(SourcePassage, literal(None).label("agent_id")) - .join(SourcesAgents, SourcesAgents.source_id == SourcePassage.source_id) - .where(SourcesAgents.agent_id == agent_id) - .where(SourcePassage.organization_id == actor.organization_id) - ) - else: - source_passages = select(SourcePassage, literal(None).label("agent_id")).where( - SourcePassage.organization_id == actor.organization_id - ) - - if source_id: - source_passages = source_passages.where(SourcePassage.source_id == source_id) - if file_id: - source_passages = source_passages.where(SourcePassage.file_id == file_id) - - # Add agent passages query - agent_passages = None - if agent_id is not None: - agent_passages = ( - select( - AgentPassage.id, - AgentPassage.text, - AgentPassage.embedding_config, - AgentPassage.metadata_, - AgentPassage.embedding, - AgentPassage.created_at, - AgentPassage.updated_at, - AgentPassage.is_deleted, - AgentPassage._created_by_id, - AgentPassage._last_updated_by_id, - AgentPassage.organization_id, - literal(None).label("file_id"), - literal(None).label("source_id"), - AgentPassage.agent_id, - ) - .where(AgentPassage.agent_id == agent_id) - .where(AgentPassage.organization_id == actor.organization_id) - ) - - # Combine queries - if source_passages is not None and agent_passages is not None: - combined_query = union_all(source_passages, agent_passages).cte("combined_passages") - elif agent_passages is not None: - combined_query = agent_passages.cte("combined_passages") - elif source_passages is not None: - combined_query = source_passages.cte("combined_passages") - else: - raise ValueError("No passages found") - - # Build main query from combined CTE - main_query = select(combined_query) - - # Apply filters - if start_date: - main_query = main_query.where(combined_query.c.created_at >= start_date) - if end_date: - main_query = main_query.where(combined_query.c.created_at <= end_date) - if source_id: - main_query = main_query.where(combined_query.c.source_id == source_id) - if file_id: - main_query = main_query.where(combined_query.c.file_id == file_id) - - # Vector search - if embedded_text: - if settings.letta_pg_uri_no_default: - # PostgreSQL with pgvector - main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc()) - else: - # SQLite with custom vector type - query_embedding_binary = adapt_array(embedded_text) - main_query = main_query.order_by( - func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(), - combined_query.c.created_at.asc() if ascending else combined_query.c.created_at.desc(), - combined_query.c.id.asc(), - ) - else: - if query_text: - main_query = main_query.where(func.lower(combined_query.c.text).contains(func.lower(query_text))) - - # Handle pagination - if before or after: - # Create reference CTEs - if before: - before_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == before).cte("before_ref") - if after: - after_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == after).cte("after_ref") - - if before and after: - # Window-based query (get records between before and after) - main_query = main_query.where( - or_( - combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(), - and_( - combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(), - combined_query.c.id < select(before_ref.c.id).scalar_subquery(), - ), - ) - ) - main_query = main_query.where( - or_( - combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(), - and_( - combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(), - combined_query.c.id > select(after_ref.c.id).scalar_subquery(), - ), - ) - ) - else: - # Pure pagination (only before or only after) - if before: - main_query = main_query.where( - or_( - combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(), - and_( - combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(), - combined_query.c.id < select(before_ref.c.id).scalar_subquery(), - ), - ) - ) - if after: - main_query = main_query.where( - or_( - combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(), - and_( - combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(), - combined_query.c.id > select(after_ref.c.id).scalar_subquery(), - ), - ) - ) - - # Add ordering if not already ordered by similarity - if not embed_query: - if ascending: - main_query = main_query.order_by( - combined_query.c.created_at.asc(), - combined_query.c.id.asc(), - ) - else: - main_query = main_query.order_by( - combined_query.c.created_at.desc(), - combined_query.c.id.asc(), - ) - - return main_query @trace_method @enforce_types @@ -2206,7 +2045,7 @@ class AgentManager: ) -> List[PydanticPassage]: """Lists all passages attached to an agent.""" with db_registry.session() as session: - main_query = self._build_passage_query( + main_query = build_passage_query( actor=actor, agent_id=agent_id, file_id=file_id, @@ -2266,7 +2105,7 @@ class AgentManager: ) -> List[PydanticPassage]: """Lists all passages attached to an agent.""" async with db_registry.async_session() as session: - main_query = self._build_passage_query( + main_query = build_passage_query( actor=actor, agent_id=agent_id, file_id=file_id, @@ -2305,6 +2144,100 @@ class AgentManager: return [p.to_pydantic() for p in passages] + @trace_method + @enforce_types + async def list_source_passages_async( + self, + actor: PydanticUser, + agent_id: Optional[str] = None, + file_id: Optional[str] = None, + limit: Optional[int] = 50, + query_text: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + before: Optional[str] = None, + after: Optional[str] = None, + source_id: Optional[str] = None, + embed_query: bool = False, + ascending: bool = True, + embedding_config: Optional[EmbeddingConfig] = None, + ) -> List[PydanticPassage]: + """Lists all passages attached to an agent.""" + async with db_registry.async_session() as session: + main_query = build_source_passage_query( + actor=actor, + agent_id=agent_id, + file_id=file_id, + query_text=query_text, + start_date=start_date, + end_date=end_date, + before=before, + after=after, + source_id=source_id, + embed_query=embed_query, + ascending=ascending, + embedding_config=embedding_config, + ) + + # Add limit + if limit: + main_query = main_query.limit(limit) + + # Execute query + result = await session.execute(main_query) + + # Get ORM objects directly using scalars() + passages = result.scalars().all() + + # Convert to Pydantic models + return [p.to_pydantic() for p in passages] + + @trace_method + @enforce_types + async def list_agent_passages_async( + self, + actor: PydanticUser, + agent_id: Optional[str] = None, + file_id: Optional[str] = None, + limit: Optional[int] = 50, + query_text: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + before: Optional[str] = None, + after: Optional[str] = None, + source_id: Optional[str] = None, + embed_query: bool = False, + ascending: bool = True, + embedding_config: Optional[EmbeddingConfig] = None, + ) -> List[PydanticPassage]: + """Lists all passages attached to an agent.""" + async with db_registry.async_session() as session: + main_query = build_agent_passage_query( + actor=actor, + agent_id=agent_id, + query_text=query_text, + start_date=start_date, + end_date=end_date, + before=before, + after=after, + embed_query=embed_query, + ascending=ascending, + embedding_config=embedding_config, + ) + + # Add limit + if limit: + main_query = main_query.limit(limit) + + # Execute query + result = await session.execute(main_query) + + # Get ORM objects directly using scalars() + passages = result.scalars().all() + + # Convert to Pydantic models + return [p.to_pydantic() for p in passages] + @trace_method @enforce_types def passage_size( @@ -2325,7 +2258,7 @@ class AgentManager: ) -> int: """Returns the count of passages matching the given criteria.""" with db_registry.session() as session: - main_query = self._build_passage_query( + main_query = build_passage_query( actor=actor, agent_id=agent_id, file_id=file_id, @@ -2363,7 +2296,7 @@ class AgentManager: agent_only: bool = False, ) -> int: async with db_registry.async_session() as session: - main_query = self._build_passage_query( + main_query = build_passage_query( actor=actor, agent_id=agent_id, file_id=file_id, @@ -2458,6 +2391,65 @@ class AgentManager: await agent.update_async(session, actor=actor) return await agent.to_pydantic_async() + @trace_method + @enforce_types + async def attach_missing_files_tools_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState: + """ + Attaches missing core file tools to an agent. + + Args: + agent_id: ID of the agent to attach the tools to. + actor: User performing the action. + + Raises: + NoResultFound: If the agent or tool is not found. + + Returns: + PydanticAgentState: The updated agent state. + """ + # Check if the agent is missing any files tools + core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE} + missing_tool_names = set(FILES_TOOLS).difference(core_tool_names) + + for tool_name in missing_tool_names: + tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor) + + # TODO: This is hacky and deserves a rethink - how do we keep all the base tools available in every org always? + if not tool_id: + await self.tool_manager.upsert_base_tools_async(actor=actor, allowed_types={ToolType.LETTA_FILES_CORE}) + + # TODO: Inefficient - I think this re-retrieves the agent_state? + agent_state = await self.attach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor) + + return agent_state + + @trace_method + @enforce_types + async def detach_all_files_tools_async(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState: + """ + Detach all core file tools from an agent. + + Args: + agent_id: ID of the agent to detach the tools from. + actor: User performing the action. + + Raises: + NoResultFound: If the agent or tool is not found. + + Returns: + PydanticAgentState: The updated agent state. + """ + # Check if the agent is missing any files tools + core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE} + + for tool_name in core_tool_names: + tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor) + + # TODO: Inefficient - I think this re-retrieves the agent_state? + agent_state = await self.detach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor) + + return agent_state + @trace_method @enforce_types def detach_tool(self, agent_id: str, tool_id: str, actor: PydanticUser) -> PydanticAgentState: diff --git a/letta/services/block_manager.py b/letta/services/block_manager.py index ce731125..12156cd4 100644 --- a/letta/services/block_manager.py +++ b/letta/services/block_manager.py @@ -9,12 +9,12 @@ from letta.orm.block import Block as BlockModel from letta.orm.block_history import BlockHistory from letta.orm.enums import ActorType from letta.orm.errors import NoResultFound +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState as PydanticAgentState from letta.schemas.block import Block as PydanticBlock from letta.schemas.block import BlockUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types logger = get_logger(__name__) diff --git a/letta/services/context_window_calculator/token_counter.py b/letta/services/context_window_calculator/token_counter.py index 764b71c3..3e1de4f7 100644 --- a/letta/services/context_window_calculator/token_counter.py +++ b/letta/services/context_window_calculator/token_counter.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from typing import Any, Dict, List from letta.llm_api.anthropic_client import AnthropicClient +from letta.schemas.openai.chat_completion_request import Tool as OpenAITool from letta.utils import count_tokens @@ -42,7 +43,7 @@ class AnthropicTokenCounter(TokenCounter): return 0 return await self.client.count_tokens(model=self.model, messages=messages) - async def count_tool_tokens(self, tools: List[Any]) -> int: + async def count_tool_tokens(self, tools: List[OpenAITool]) -> int: if not tools: return 0 return await self.client.count_tokens(model=self.model, tools=tools) @@ -69,7 +70,7 @@ class TiktokenCounter(TokenCounter): return num_tokens_from_messages(messages=messages, model=self.model) - async def count_tool_tokens(self, tools: List[Any]) -> int: + async def count_tool_tokens(self, tools: List[OpenAITool]) -> int: if not tools: return 0 from letta.local_llm.utils import num_tokens_from_functions diff --git a/letta/services/file_processor/chunker/line_chunker.py b/letta/services/file_processor/chunker/line_chunker.py new file mode 100644 index 00000000..fcda9516 --- /dev/null +++ b/letta/services/file_processor/chunker/line_chunker.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +from letta.log import get_logger + +logger = get_logger(__name__) + + +class LineChunker: + """Newline chunker""" + + def __init__(self): + pass + + # TODO: Make this more general beyond Mistral + def chunk_text(self, text: str, start: Optional[int] = None, end: Optional[int] = None) -> List[str]: + """Split lines""" + content_lines = [line.strip() for line in text.split("\n") if line.strip()] + total_lines = len(content_lines) + + if start and end: + content_lines = content_lines[start:end] + line_offset = start + else: + line_offset = 0 + + content_lines = [f"Line {i + line_offset}: {line}" for i, line in enumerate(content_lines)] + + # Add metadata about total lines + if start and end: + content_lines.insert(0, f"[Viewing lines {start} to {end} (out of {total_lines} lines)]") + else: + content_lines.insert(0, f"[Viewing file start (out of {total_lines} lines)]") + + return content_lines diff --git a/letta/services/file_processor/file_processor.py b/letta/services/file_processor/file_processor.py index 0854d049..533723f1 100644 --- a/letta/services/file_processor/file_processor.py +++ b/letta/services/file_processor/file_processor.py @@ -5,12 +5,13 @@ from fastapi import UploadFile from letta.log import get_logger from letta.schemas.agent import AgentState -from letta.schemas.enums import JobStatus +from letta.schemas.enums import FileProcessingStatus, JobStatus from letta.schemas.file import FileMetadata from letta.schemas.job import Job, JobUpdate from letta.schemas.passage import Passage from letta.schemas.user import User from letta.server.server import SyncServer +from letta.services.file_processor.chunker.line_chunker import LineChunker from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker from letta.services.file_processor.embedder.openai_embedder import OpenAIEmbedder from letta.services.file_processor.parser.mistral_parser import MistralFileParser @@ -34,6 +35,7 @@ class FileProcessor: ): self.file_parser = file_parser self.text_chunker = text_chunker + self.line_chunker = LineChunker() self.embedder = embedder self.max_file_size = max_file_size self.source_manager = SourceManager() @@ -52,9 +54,12 @@ class FileProcessor: job: Optional[Job] = None, ) -> List[Passage]: file_metadata = self._extract_upload_file_metadata(file, source_id=source_id) - file_metadata = await self.source_manager.create_file(file_metadata, self.actor) filename = file_metadata.file_name + # Create file as early as possible with no content + file_metadata.processing_status = FileProcessingStatus.PARSING # Parsing now + file_metadata = await self.source_manager.create_file(file_metadata, self.actor) + try: # Ensure we're working with bytes if isinstance(content, str): @@ -66,11 +71,35 @@ class FileProcessor: logger.info(f"Starting OCR extraction for {filename}") ocr_response = await self.file_parser.extract_text(content, mime_type=file_metadata.file_type) + # update file with raw text + raw_markdown_text = "".join([page.markdown for page in ocr_response.pages]) + file_metadata = await self.source_manager.upsert_file_content( + file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor + ) + file_metadata = await self.source_manager.update_file_status( + file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING + ) + + # Insert to agent context window + # TODO: Rethink this line chunking mechanism + content_lines = self.line_chunker.chunk_text(text=raw_markdown_text) + visible_content = "\n".join(content_lines) + + await server.insert_file_into_context_windows( + source_id=source_id, + text=visible_content, + file_id=file_metadata.id, + file_name=file_metadata.file_name, + actor=self.actor, + agent_states=agent_states, + ) + if not ocr_response or len(ocr_response.pages) == 0: raise ValueError("No text extracted from PDF") logger.info("Chunking extracted text") all_passages = [] + for page in ocr_response.pages: chunks = self.text_chunker.chunk_text(page) @@ -86,24 +115,20 @@ class FileProcessor: logger.info(f"Successfully processed {filename}: {len(all_passages)} passages") - await server.insert_file_into_context_windows( - source_id=source_id, - text="".join([ocr_response.pages[i].markdown for i in range(min(3, len(ocr_response.pages)))]), - file_id=file_metadata.id, - actor=self.actor, - agent_states=agent_states, - ) - # update job status if job: job.status = JobStatus.completed job.metadata["num_passages"] = len(all_passages) await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor) + await self.source_manager.update_file_status( + file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED + ) + return all_passages except Exception as e: - logger.error(f"PDF processing failed for {filename}: {str(e)}") + logger.error(f"File processing failed for {filename}: {str(e)}") # update job status if job: @@ -111,6 +136,10 @@ class FileProcessor: job.metadata["error"] = str(e) await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor) + await self.source_manager.update_file_status( + file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e) + ) + return [] def _extract_upload_file_metadata(self, file: UploadFile, source_id: str) -> FileMetadata: diff --git a/letta/services/file_processor/parser/mistral_parser.py b/letta/services/file_processor/parser/mistral_parser.py index 59eb7084..6c68b71e 100644 --- a/letta/services/file_processor/parser/mistral_parser.py +++ b/letta/services/file_processor/parser/mistral_parser.py @@ -9,6 +9,16 @@ from letta.settings import settings logger = get_logger(__name__) +SIMPLE_TEXT_MIME_TYPES = { + "text/plain", + "text/markdown", + "text/x-markdown", + "application/json", + "application/jsonl", + "application/x-jsonlines", +} + + class MistralFileParser(FileParser): """Mistral-based OCR extraction""" @@ -23,7 +33,7 @@ class MistralFileParser(FileParser): # TODO: Kind of hacky...we try to exit early here? # TODO: Create our internal file parser representation we return instead of OCRResponse - if mime_type == "text/plain": + if mime_type in SIMPLE_TEXT_MIME_TYPES or mime_type.startswith("text/"): text = content.decode("utf-8", errors="replace") return OCRResponse( model=self.model, diff --git a/letta/services/files_agents_manager.py b/letta/services/files_agents_manager.py index 155e8322..a9bb7a6d 100644 --- a/letta/services/files_agents_manager.py +++ b/letta/services/files_agents_manager.py @@ -5,10 +5,11 @@ from sqlalchemy import and_, func, select, update from letta.orm.errors import NoResultFound from letta.orm.files_agents import FileAgent as FileAgentModel +from letta.otel.tracing import trace_method +from letta.schemas.block import Block as PydanticBlock from letta.schemas.file import FileAgent as PydanticFileAgent from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types @@ -22,6 +23,7 @@ class FileAgentManager: *, agent_id: str, file_id: str, + file_name: str, actor: PydanticUser, is_open: bool = True, visible_content: Optional[str] = None, @@ -38,6 +40,7 @@ class FileAgentManager: and_( FileAgentModel.agent_id == agent_id, FileAgentModel.file_id == file_id, + FileAgentModel.file_name == file_name, FileAgentModel.organization_id == actor.organization_id, ) ) @@ -61,6 +64,7 @@ class FileAgentManager: assoc = FileAgentModel( agent_id=agent_id, file_id=file_id, + file_name=file_name, organization_id=actor.organization_id, is_open=is_open, visible_content=visible_content, @@ -71,7 +75,7 @@ class FileAgentManager: @enforce_types @trace_method - async def update_file_agent( + async def update_file_agent_by_id( self, *, agent_id: str, @@ -82,7 +86,33 @@ class FileAgentManager: ) -> PydanticFileAgent: """Patch an existing association row.""" async with db_registry.async_session() as session: - assoc = await self._get_association(session, agent_id, file_id, actor) + assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor) + + if is_open is not None: + assoc.is_open = is_open + if visible_content is not None: + assoc.visible_content = visible_content + + # touch timestamp + assoc.last_accessed_at = datetime.now(timezone.utc) + + await assoc.update_async(session, actor=actor) + return assoc.to_pydantic() + + @enforce_types + @trace_method + async def update_file_agent_by_name( + self, + *, + agent_id: str, + file_name: str, + actor: PydanticUser, + is_open: Optional[bool] = None, + visible_content: Optional[str] = None, + ) -> PydanticFileAgent: + """Patch an existing association row.""" + async with db_registry.async_session() as session: + assoc = await self._get_association_by_file_name(session, agent_id, file_name, actor) if is_open is not None: assoc.is_open = is_open @@ -100,15 +130,61 @@ class FileAgentManager: async def detach_file(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> None: """Hard-delete the association.""" async with db_registry.async_session() as session: - assoc = await self._get_association(session, agent_id, file_id, actor) + assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor) await assoc.hard_delete_async(session, actor=actor) @enforce_types @trace_method - async def get_file_agent(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]: + async def get_file_agent_by_id(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]: async with db_registry.async_session() as session: try: - assoc = await self._get_association(session, agent_id, file_id, actor) + assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor) + return assoc.to_pydantic() + except NoResultFound: + return None + + @enforce_types + @trace_method + async def get_all_file_blocks_by_name( + self, + *, + file_names: List[str], + actor: PydanticUser, + ) -> List[PydanticBlock]: + """ + Retrieve multiple FileAgent associations by their IDs in a single query. + + Args: + file_names: List of file names to retrieve + actor: The user making the request + + Returns: + List of PydanticFileAgent objects found (may be fewer than requested if some IDs don't exist) + """ + if not file_names: + return [] + + async with db_registry.async_session() as session: + # Use IN clause for efficient bulk retrieval + query = select(FileAgentModel).where( + and_( + FileAgentModel.file_name.in_(file_names), + FileAgentModel.organization_id == actor.organization_id, + ) + ) + + # Execute query and get all results + rows = (await session.execute(query)).scalars().all() + + # Convert to Pydantic models + return [row.to_pydantic_block() for row in rows] + + @enforce_types + @trace_method + async def get_file_agent_by_file_name(self, *, agent_id: str, file_name: str, actor: PydanticUser) -> Optional[PydanticFileAgent]: + async with db_registry.async_session() as session: + try: + assoc = await self._get_association_by_file_name(session, agent_id, file_name, actor) return assoc.to_pydantic() except NoResultFound: return None @@ -170,7 +246,7 @@ class FileAgentManager: await session.execute(stmt) await session.commit() - async def _get_association(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel: + async def _get_association_by_file_id(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel: q = select(FileAgentModel).where( and_( FileAgentModel.agent_id == agent_id, @@ -182,3 +258,16 @@ class FileAgentManager: if not assoc: raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_id={file_id}) not found in org {actor.organization_id}") return assoc + + async def _get_association_by_file_name(self, session, agent_id: str, file_name: str, actor: PydanticUser) -> FileAgentModel: + q = select(FileAgentModel).where( + and_( + FileAgentModel.agent_id == agent_id, + FileAgentModel.file_name == file_name, + FileAgentModel.organization_id == actor.organization_id, + ) + ) + assoc = await session.scalar(q) + if not assoc: + raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_name={file_name}) not found in org {actor.organization_id}") + return assoc diff --git a/letta/services/group_manager.py b/letta/services/group_manager.py index 1884e5d0..d2b0a501 100644 --- a/letta/services/group_manager.py +++ b/letta/services/group_manager.py @@ -7,13 +7,13 @@ from letta.orm.agent import Agent as AgentModel from letta.orm.errors import NoResultFound from letta.orm.group import Group as GroupModel from letta.orm.message import Message as MessageModel +from letta.otel.tracing import trace_method from letta.schemas.group import Group as PydanticGroup from letta.schemas.group import GroupCreate, GroupUpdate, ManagerType from letta.schemas.letta_message import LettaMessage from letta.schemas.message import Message as PydanticMessage from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types @@ -152,9 +152,9 @@ class GroupManager: @trace_method @enforce_types - def modify_group(self, group_id: str, group_update: GroupUpdate, actor: PydanticUser) -> PydanticGroup: - with db_registry.session() as session: - group = GroupModel.read(db_session=session, identifier=group_id, actor=actor) + async def modify_group_async(self, group_id: str, group_update: GroupUpdate, actor: PydanticUser) -> PydanticGroup: + async with db_registry.async_session() as session: + group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor) sleeptime_agent_frequency = None max_message_buffer_length = None @@ -206,11 +206,11 @@ class GroupManager: if group_update.description: group.description = group_update.description if group_update.agent_ids: - self._process_agent_relationship( + await self._process_agent_relationship_async( session=session, group=group, agent_ids=group_update.agent_ids, allow_partial=False, replace=True ) - group.update(session, actor=actor) + await group.update_async(session, actor=actor) return group.to_pydantic() @trace_method diff --git a/letta/services/helpers/agent_manager_helper.py b/letta/services/helpers/agent_manager_helper.py index 7c1429ad..7d83b1ab 100644 --- a/letta/services/helpers/agent_manager_helper.py +++ b/letta/services/helpers/agent_manager_helper.py @@ -1,27 +1,33 @@ import datetime from typing import List, Literal, Optional -from sqlalchemy import and_, asc, desc, or_, select +import numpy as np +from sqlalchemy import Select, and_, asc, desc, func, literal, or_, select, union_all from sqlalchemy.sql.expression import exists from letta import system -from letta.constants import IN_CONTEXT_MEMORY_KEYWORD, STRUCTURED_OUTPUT_MODELS +from letta.constants import IN_CONTEXT_MEMORY_KEYWORD, MAX_EMBEDDING_DIM, STRUCTURED_OUTPUT_MODELS +from letta.embeddings import embedding_model from letta.helpers import ToolRulesSolver from letta.helpers.datetime_helpers import get_local_time, get_local_time_fast +from letta.orm import AgentPassage, SourcePassage, SourcesAgents from letta.orm.agent import Agent as AgentModel from letta.orm.agents_tags import AgentsTags from letta.orm.errors import NoResultFound from letta.orm.identity import Identity +from letta.orm.sqlite_functions import adapt_array +from letta.otel.tracing import trace_method from letta.prompts import gpt_system from letta.schemas.agent import AgentState, AgentType +from letta.schemas.embedding_config import EmbeddingConfig from letta.schemas.enums import MessageRole from letta.schemas.letta_message_content import TextContent from letta.schemas.memory import Memory from letta.schemas.message import Message, MessageCreate from letta.schemas.tool_rule import ToolRule from letta.schemas.user import User +from letta.settings import settings from letta.system import get_initial_boot_messages, get_login_event, package_function_response -from letta.tracing import trace_method # Static methods @@ -566,3 +572,367 @@ def _apply_filters( if base_template_id: query = query.where(AgentModel.base_template_id == base_template_id) return query + + +def build_passage_query( + actor: User, + agent_id: Optional[str] = None, + file_id: Optional[str] = None, + query_text: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + before: Optional[str] = None, + after: Optional[str] = None, + source_id: Optional[str] = None, + embed_query: bool = False, + ascending: bool = True, + embedding_config: Optional[EmbeddingConfig] = None, + agent_only: bool = False, +) -> Select: + """Helper function to build the base passage query with all filters applied. + Supports both before and after pagination across merged source and agent passages. + + Returns the query before any limit or count operations are applied. + """ + embedded_text = None + if embed_query: + assert embedding_config is not None, "embedding_config must be specified for vector search" + assert query_text is not None, "query_text must be specified for vector search" + embedded_text = embedding_model(embedding_config).get_text_embedding(query_text) + embedded_text = np.array(embedded_text) + embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist() + + # Start with base query for source passages + source_passages = None + if not agent_only: # Include source passages + if agent_id is not None: + source_passages = ( + select(SourcePassage, literal(None).label("agent_id")) + .join(SourcesAgents, SourcesAgents.source_id == SourcePassage.source_id) + .where(SourcesAgents.agent_id == agent_id) + .where(SourcePassage.organization_id == actor.organization_id) + ) + else: + source_passages = select(SourcePassage, literal(None).label("agent_id")).where( + SourcePassage.organization_id == actor.organization_id + ) + + if source_id: + source_passages = source_passages.where(SourcePassage.source_id == source_id) + if file_id: + source_passages = source_passages.where(SourcePassage.file_id == file_id) + + # Add agent passages query + agent_passages = None + if agent_id is not None: + agent_passages = ( + select( + AgentPassage.id, + AgentPassage.text, + AgentPassage.embedding_config, + AgentPassage.metadata_, + AgentPassage.embedding, + AgentPassage.created_at, + AgentPassage.updated_at, + AgentPassage.is_deleted, + AgentPassage._created_by_id, + AgentPassage._last_updated_by_id, + AgentPassage.organization_id, + literal(None).label("file_id"), + literal(None).label("source_id"), + AgentPassage.agent_id, + ) + .where(AgentPassage.agent_id == agent_id) + .where(AgentPassage.organization_id == actor.organization_id) + ) + + # Combine queries + if source_passages is not None and agent_passages is not None: + combined_query = union_all(source_passages, agent_passages).cte("combined_passages") + elif agent_passages is not None: + combined_query = agent_passages.cte("combined_passages") + elif source_passages is not None: + combined_query = source_passages.cte("combined_passages") + else: + raise ValueError("No passages found") + + # Build main query from combined CTE + main_query = select(combined_query) + + # Apply filters + if start_date: + main_query = main_query.where(combined_query.c.created_at >= start_date) + if end_date: + main_query = main_query.where(combined_query.c.created_at <= end_date) + if source_id: + main_query = main_query.where(combined_query.c.source_id == source_id) + if file_id: + main_query = main_query.where(combined_query.c.file_id == file_id) + + # Vector search + if embedded_text: + if settings.letta_pg_uri_no_default: + # PostgreSQL with pgvector + main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc()) + else: + # SQLite with custom vector type + query_embedding_binary = adapt_array(embedded_text) + main_query = main_query.order_by( + func.cosine_distance(combined_query.c.embedding, query_embedding_binary).asc(), + combined_query.c.created_at.asc() if ascending else combined_query.c.created_at.desc(), + combined_query.c.id.asc(), + ) + else: + if query_text: + main_query = main_query.where(func.lower(combined_query.c.text).contains(func.lower(query_text))) + + # Handle pagination + if before or after: + # Create reference CTEs + if before: + before_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == before).cte("before_ref") + if after: + after_ref = select(combined_query.c.created_at, combined_query.c.id).where(combined_query.c.id == after).cte("after_ref") + + if before and after: + # Window-based query (get records between before and after) + main_query = main_query.where( + or_( + combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(), + and_( + combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(), + combined_query.c.id < select(before_ref.c.id).scalar_subquery(), + ), + ) + ) + main_query = main_query.where( + or_( + combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(), + and_( + combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(), + combined_query.c.id > select(after_ref.c.id).scalar_subquery(), + ), + ) + ) + else: + # Pure pagination (only before or only after) + if before: + main_query = main_query.where( + or_( + combined_query.c.created_at < select(before_ref.c.created_at).scalar_subquery(), + and_( + combined_query.c.created_at == select(before_ref.c.created_at).scalar_subquery(), + combined_query.c.id < select(before_ref.c.id).scalar_subquery(), + ), + ) + ) + if after: + main_query = main_query.where( + or_( + combined_query.c.created_at > select(after_ref.c.created_at).scalar_subquery(), + and_( + combined_query.c.created_at == select(after_ref.c.created_at).scalar_subquery(), + combined_query.c.id > select(after_ref.c.id).scalar_subquery(), + ), + ) + ) + + # Add ordering if not already ordered by similarity + if not embed_query: + if ascending: + main_query = main_query.order_by( + combined_query.c.created_at.asc(), + combined_query.c.id.asc(), + ) + else: + main_query = main_query.order_by( + combined_query.c.created_at.desc(), + combined_query.c.id.asc(), + ) + + return main_query + + +def build_source_passage_query( + actor: User, + agent_id: Optional[str] = None, + file_id: Optional[str] = None, + query_text: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + before: Optional[str] = None, + after: Optional[str] = None, + source_id: Optional[str] = None, + embed_query: bool = False, + ascending: bool = True, + embedding_config: Optional[EmbeddingConfig] = None, +) -> Select: + """Build query for source passages with all filters applied.""" + + # Handle embedding for vector search + embedded_text = None + if embed_query: + assert embedding_config is not None, "embedding_config must be specified for vector search" + assert query_text is not None, "query_text must be specified for vector search" + embedded_text = embedding_model(embedding_config).get_text_embedding(query_text) + embedded_text = np.array(embedded_text) + embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist() + + # Base query for source passages + query = select(SourcePassage).where(SourcePassage.organization_id == actor.organization_id) + + # If agent_id is specified, join with SourcesAgents to get only passages linked to that agent + if agent_id is not None: + query = query.join(SourcesAgents, SourcesAgents.source_id == SourcePassage.source_id) + query = query.where(SourcesAgents.agent_id == agent_id) + + # Apply filters + if source_id: + query = query.where(SourcePassage.source_id == source_id) + if file_id: + query = query.where(SourcePassage.file_id == file_id) + if start_date: + query = query.where(SourcePassage.created_at >= start_date) + if end_date: + query = query.where(SourcePassage.created_at <= end_date) + + # Handle text search or vector search + if embedded_text: + if settings.letta_pg_uri_no_default: + # PostgreSQL with pgvector + query = query.order_by(SourcePassage.embedding.cosine_distance(embedded_text).asc()) + else: + # SQLite with custom vector type + query_embedding_binary = adapt_array(embedded_text) + query = query.order_by( + func.cosine_distance(SourcePassage.embedding, query_embedding_binary).asc(), + SourcePassage.created_at.asc() if ascending else SourcePassage.created_at.desc(), + SourcePassage.id.asc(), + ) + else: + if query_text: + query = query.where(func.lower(SourcePassage.text).contains(func.lower(query_text))) + + # Handle pagination + if before or after: + if before: + # Get the reference record + before_subq = select(SourcePassage.created_at, SourcePassage.id).where(SourcePassage.id == before).subquery() + query = query.where( + or_( + SourcePassage.created_at < before_subq.c.created_at, + and_( + SourcePassage.created_at == before_subq.c.created_at, + SourcePassage.id < before_subq.c.id, + ), + ) + ) + + if after: + # Get the reference record + after_subq = select(SourcePassage.created_at, SourcePassage.id).where(SourcePassage.id == after).subquery() + query = query.where( + or_( + SourcePassage.created_at > after_subq.c.created_at, + and_( + SourcePassage.created_at == after_subq.c.created_at, + SourcePassage.id > after_subq.c.id, + ), + ) + ) + + # Apply ordering if not already ordered by similarity + if not embed_query: + if ascending: + query = query.order_by(SourcePassage.created_at.asc(), SourcePassage.id.asc()) + else: + query = query.order_by(SourcePassage.created_at.desc(), SourcePassage.id.asc()) + + return query + + +def build_agent_passage_query( + actor: User, + agent_id: str, # Required for agent passages + query_text: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + before: Optional[str] = None, + after: Optional[str] = None, + embed_query: bool = False, + ascending: bool = True, + embedding_config: Optional[EmbeddingConfig] = None, +) -> Select: + """Build query for agent passages with all filters applied.""" + + # Handle embedding for vector search + embedded_text = None + if embed_query: + assert embedding_config is not None, "embedding_config must be specified for vector search" + assert query_text is not None, "query_text must be specified for vector search" + embedded_text = embedding_model(embedding_config).get_text_embedding(query_text) + embedded_text = np.array(embedded_text) + embedded_text = np.pad(embedded_text, (0, MAX_EMBEDDING_DIM - embedded_text.shape[0]), mode="constant").tolist() + + # Base query for agent passages + query = select(AgentPassage).where(AgentPassage.agent_id == agent_id, AgentPassage.organization_id == actor.organization_id) + + # Apply filters + if start_date: + query = query.where(AgentPassage.created_at >= start_date) + if end_date: + query = query.where(AgentPassage.created_at <= end_date) + + # Handle text search or vector search + if embedded_text: + if settings.letta_pg_uri_no_default: + # PostgreSQL with pgvector + query = query.order_by(AgentPassage.embedding.cosine_distance(embedded_text).asc()) + else: + # SQLite with custom vector type + query_embedding_binary = adapt_array(embedded_text) + query = query.order_by( + func.cosine_distance(AgentPassage.embedding, query_embedding_binary).asc(), + AgentPassage.created_at.asc() if ascending else AgentPassage.created_at.desc(), + AgentPassage.id.asc(), + ) + else: + if query_text: + query = query.where(func.lower(AgentPassage.text).contains(func.lower(query_text))) + + # Handle pagination + if before or after: + if before: + # Get the reference record + before_subq = select(AgentPassage.created_at, AgentPassage.id).where(AgentPassage.id == before).subquery() + query = query.where( + or_( + AgentPassage.created_at < before_subq.c.created_at, + and_( + AgentPassage.created_at == before_subq.c.created_at, + AgentPassage.id < before_subq.c.id, + ), + ) + ) + + if after: + # Get the reference record + after_subq = select(AgentPassage.created_at, AgentPassage.id).where(AgentPassage.id == after).subquery() + query = query.where( + or_( + AgentPassage.created_at > after_subq.c.created_at, + and_( + AgentPassage.created_at == after_subq.c.created_at, + AgentPassage.id > after_subq.c.id, + ), + ) + ) + + # Apply ordering if not already ordered by similarity + if not embed_query: + if ascending: + query = query.order_by(AgentPassage.created_at.asc(), AgentPassage.id.asc()) + else: + query = query.order_by(AgentPassage.created_at.desc(), AgentPassage.id.asc()) + + return query diff --git a/letta/services/identity_manager.py b/letta/services/identity_manager.py index f91854fd..abf637ba 100644 --- a/letta/services/identity_manager.py +++ b/letta/services/identity_manager.py @@ -7,11 +7,11 @@ from sqlalchemy.exc import NoResultFound from letta.orm.agent import Agent as AgentModel from letta.orm.block import Block as BlockModel from letta.orm.identity import Identity as IdentityModel +from letta.otel.tracing import trace_method from letta.schemas.identity import Identity as PydanticIdentity from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityType, IdentityUpdate, IdentityUpsert from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types diff --git a/letta/services/job_manager.py b/letta/services/job_manager.py index 11361b35..541d7514 100644 --- a/letta/services/job_manager.py +++ b/letta/services/job_manager.py @@ -14,6 +14,7 @@ from letta.orm.message import Message as MessageModel from letta.orm.sqlalchemy_base import AccessType from letta.orm.step import Step from letta.orm.step import Step as StepModel +from letta.otel.tracing import trace_method from letta.schemas.enums import JobStatus, MessageRole from letta.schemas.job import BatchJob as PydanticBatchJob from letta.schemas.job import Job as PydanticJob @@ -25,7 +26,6 @@ from letta.schemas.step import Step as PydanticStep from letta.schemas.usage import LettaUsageStatistics from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types diff --git a/letta/services/llm_batch_manager.py b/letta/services/llm_batch_manager.py index eeff0673..4700d146 100644 --- a/letta/services/llm_batch_manager.py +++ b/letta/services/llm_batch_manager.py @@ -9,6 +9,7 @@ from letta.log import get_logger from letta.orm import Message as MessageModel from letta.orm.llm_batch_items import LLMBatchItem from letta.orm.llm_batch_job import LLMBatchJob +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentStepState from letta.schemas.enums import AgentStepStatus, JobStatus, ProviderType from letta.schemas.llm_batch_job import LLMBatchItem as PydanticLLMBatchItem @@ -17,7 +18,6 @@ from letta.schemas.llm_config import LLMConfig from letta.schemas.message import Message as PydanticMessage from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types logger = get_logger(__name__) diff --git a/letta/services/message_manager.py b/letta/services/message_manager.py index 01763689..45a6fc6f 100644 --- a/letta/services/message_manager.py +++ b/letta/services/message_manager.py @@ -7,13 +7,13 @@ from letta.log import get_logger from letta.orm.agent import Agent as AgentModel from letta.orm.errors import NoResultFound from letta.orm.message import Message as MessageModel +from letta.otel.tracing import trace_method from letta.schemas.enums import MessageRole from letta.schemas.letta_message import LettaMessageUpdateUnion from letta.schemas.message import Message as PydanticMessage from letta.schemas.message import MessageUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types logger = get_logger(__name__) diff --git a/letta/services/organization_manager.py b/letta/services/organization_manager.py index 08f8f70a..23fe92c8 100644 --- a/letta/services/organization_manager.py +++ b/letta/services/organization_manager.py @@ -2,10 +2,10 @@ from typing import List, Optional from letta.orm.errors import NoResultFound from letta.orm.organization import Organization as OrganizationModel +from letta.otel.tracing import trace_method from letta.schemas.organization import Organization as PydanticOrganization from letta.schemas.organization import OrganizationUpdate from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types diff --git a/letta/services/passage_manager.py b/letta/services/passage_manager.py index 99f7d6c4..b60e2e1b 100644 --- a/letta/services/passage_manager.py +++ b/letta/services/passage_manager.py @@ -11,11 +11,11 @@ from letta.constants import MAX_EMBEDDING_DIM from letta.embeddings import embedding_model, parse_and_chunk_text from letta.orm.errors import NoResultFound from letta.orm.passage import AgentPassage, SourcePassage +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState from letta.schemas.passage import Passage as PydanticPassage from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types diff --git a/letta/services/per_agent_lock_manager.py b/letta/services/per_agent_lock_manager.py index e8e2a0a4..aff76a1f 100644 --- a/letta/services/per_agent_lock_manager.py +++ b/letta/services/per_agent_lock_manager.py @@ -1,7 +1,7 @@ import threading from collections import defaultdict -from letta.tracing import trace_method +from letta.otel.tracing import trace_method class PerAgentLockManager: diff --git a/letta/services/provider_manager.py b/letta/services/provider_manager.py index 0e9cf948..bdb7bd24 100644 --- a/letta/services/provider_manager.py +++ b/letta/services/provider_manager.py @@ -1,12 +1,12 @@ from typing import List, Optional, Union from letta.orm.provider import Provider as ProviderModel +from letta.otel.tracing import trace_method from letta.schemas.enums import ProviderCategory, ProviderType from letta.schemas.providers import Provider as PydanticProvider from letta.schemas.providers import ProviderCheck, ProviderCreate, ProviderUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types diff --git a/letta/services/sandbox_config_manager.py b/letta/services/sandbox_config_manager.py index 211f6975..d9a8bffb 100644 --- a/letta/services/sandbox_config_manager.py +++ b/letta/services/sandbox_config_manager.py @@ -5,6 +5,7 @@ from letta.log import get_logger from letta.orm.errors import NoResultFound from letta.orm.sandbox_config import SandboxConfig as SandboxConfigModel from letta.orm.sandbox_config import SandboxEnvironmentVariable as SandboxEnvVarModel +from letta.otel.tracing import trace_method from letta.schemas.environment_variables import SandboxEnvironmentVariable as PydanticEnvVar from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate from letta.schemas.sandbox_config import LocalSandboxConfig @@ -12,7 +13,6 @@ from letta.schemas.sandbox_config import SandboxConfig as PydanticSandboxConfig from letta.schemas.sandbox_config import SandboxConfigCreate, SandboxConfigUpdate, SandboxType from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types, printd logger = get_logger(__name__) diff --git a/letta/services/source_manager.py b/letta/services/source_manager.py index 7580f77e..614c0c31 100644 --- a/letta/services/source_manager.py +++ b/letta/services/source_manager.py @@ -1,16 +1,25 @@ import asyncio +from datetime import datetime from typing import List, Optional +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + from letta.orm.errors import NoResultFound +from letta.orm.file import FileContent as FileContentModel from letta.orm.file import FileMetadata as FileMetadataModel from letta.orm.source import Source as SourceModel +from letta.orm.sqlalchemy_base import AccessType +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState as PydanticAgentState +from letta.schemas.enums import FileProcessingStatus from letta.schemas.file import FileMetadata as PydanticFileMetadata from letta.schemas.source import Source as PydanticSource from letta.schemas.source import SourceUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.tracing import trace_method from letta.utils import enforce_types, printd @@ -142,41 +151,191 @@ class SourceManager: @enforce_types @trace_method - async def create_file(self, file_metadata: PydanticFileMetadata, actor: PydanticUser) -> PydanticFileMetadata: - """Create a new file based on the PydanticFileMetadata schema.""" - db_file = await self.get_file_by_id(file_metadata.id, actor=actor) - if db_file: - return db_file - else: - async with db_registry.async_session() as session: + async def create_file( + self, + file_metadata: PydanticFileMetadata, + actor: PydanticUser, + *, + text: Optional[str] = None, + ) -> PydanticFileMetadata: + + # short-circuit if it already exists + existing = await self.get_file_by_id(file_metadata.id, actor=actor) + if existing: + return existing + + async with db_registry.async_session() as session: + try: file_metadata.organization_id = actor.organization_id - file_metadata = FileMetadataModel(**file_metadata.model_dump(to_orm=True, exclude_none=True)) - await file_metadata.create_async(session, actor=actor) - return file_metadata.to_pydantic() + file_orm = FileMetadataModel(**file_metadata.model_dump(to_orm=True, exclude_none=True)) + await file_orm.create_async(session, actor=actor, no_commit=True) + + if text is not None: + content_orm = FileContentModel(file_id=file_orm.id, text=text) + await content_orm.create_async(session, actor=actor, no_commit=True) + + await session.commit() + await session.refresh(file_orm) + return await file_orm.to_pydantic_async() + + except IntegrityError: + await session.rollback() + return await self.get_file_by_id(file_metadata.id, actor=actor) # TODO: We make actor optional for now, but should most likely be enforced due to security reasons @enforce_types @trace_method - async def get_file_by_id(self, file_id: str, actor: Optional[PydanticUser] = None) -> Optional[PydanticFileMetadata]: - """Retrieve a file by its ID.""" + async def get_file_by_id( + self, + file_id: str, + actor: Optional[PydanticUser] = None, + *, + include_content: bool = False, + ) -> Optional[PydanticFileMetadata]: + """Retrieve a file by its ID. + + If `include_content=True`, the FileContent relationship is eagerly + loaded so `to_pydantic(include_content=True)` never triggers a + lazy SELECT (avoids MissingGreenlet). + """ async with db_registry.async_session() as session: try: - file = await FileMetadataModel.read_async(db_session=session, identifier=file_id, actor=actor) - return file.to_pydantic() + if include_content: + # explicit eager load + query = ( + select(FileMetadataModel).where(FileMetadataModel.id == file_id).options(selectinload(FileMetadataModel.content)) + ) + # apply org-scoping if actor provided + if actor: + query = FileMetadataModel.apply_access_predicate( + query, + actor, + access=["read"], + access_type=AccessType.ORGANIZATION, + ) + + result = await session.execute(query) + file_orm = result.scalar_one() + else: + # fast path (metadata only) + file_orm = await FileMetadataModel.read_async( + db_session=session, + identifier=file_id, + actor=actor, + ) + + return await file_orm.to_pydantic_async(include_content=include_content) + except NoResultFound: return None + @enforce_types + @trace_method + async def update_file_status( + self, + *, + file_id: str, + actor: PydanticUser, + processing_status: Optional[FileProcessingStatus] = None, + error_message: Optional[str] = None, + ) -> PydanticFileMetadata: + """ + Update processing_status and/or error_message on a FileMetadata row. + + * 1st round-trip → UPDATE + * 2nd round-trip → SELECT fresh row (same as read_async) + """ + + if processing_status is None and error_message is None: + raise ValueError("Nothing to update") + + values: dict[str, object] = {"updated_at": datetime.utcnow()} + if processing_status is not None: + values["processing_status"] = processing_status + if error_message is not None: + values["error_message"] = error_message + + async with db_registry.async_session() as session: + # Fast in-place update – no ORM hydration + stmt = ( + update(FileMetadataModel) + .where( + FileMetadataModel.id == file_id, + FileMetadataModel.organization_id == actor.organization_id, + ) + .values(**values) + ) + await session.execute(stmt) + await session.commit() + + # Reload via normal accessor so we return a fully-attached object + file_orm = await FileMetadataModel.read_async( + db_session=session, + identifier=file_id, + actor=actor, + ) + return await file_orm.to_pydantic_async() + + @enforce_types + @trace_method + async def upsert_file_content( + self, + *, + file_id: str, + text: str, + actor: PydanticUser, + ) -> PydanticFileMetadata: + async with db_registry.async_session() as session: + await FileMetadataModel.read_async(session, file_id, actor) + + dialect_name = session.bind.dialect.name + + if dialect_name == "postgresql": + stmt = ( + pg_insert(FileContentModel) + .values(file_id=file_id, text=text) + .on_conflict_do_update( + index_elements=[FileContentModel.file_id], + set_={"text": text}, + ) + ) + await session.execute(stmt) + else: + # Emulate upsert for SQLite and others + stmt = select(FileContentModel).where(FileContentModel.file_id == file_id) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + await session.execute(update(FileContentModel).where(FileContentModel.file_id == file_id).values(text=text)) + else: + session.add(FileContentModel(file_id=file_id, text=text)) + + await session.commit() + + # Reload with content + query = select(FileMetadataModel).options(selectinload(FileMetadataModel.content)).where(FileMetadataModel.id == file_id) + result = await session.execute(query) + return await result.scalar_one().to_pydantic_async(include_content=True) + @enforce_types @trace_method async def list_files( - self, source_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50 + self, source_id: str, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50, include_content: bool = False ) -> List[PydanticFileMetadata]: """List all files with optional pagination.""" async with db_registry.async_session() as session: + options = [selectinload(FileMetadataModel.content)] if include_content else None + files = await FileMetadataModel.list_async( - db_session=session, after=after, limit=limit, organization_id=actor.organization_id, source_id=source_id + db_session=session, + after=after, + limit=limit, + organization_id=actor.organization_id, + source_id=source_id, + query_options=options, ) - return [file.to_pydantic() for file in files] + return [await file.to_pydantic_async(include_content=include_content) for file in files] @enforce_types @trace_method @@ -185,4 +344,4 @@ class SourceManager: async with db_registry.async_session() as session: file = await FileMetadataModel.read_async(db_session=session, identifier=file_id) await file.hard_delete_async(db_session=session, actor=actor) - return file.to_pydantic() + return await file.to_pydantic_async() diff --git a/letta/services/step_manager.py b/letta/services/step_manager.py index ea30e2d5..9401af48 100644 --- a/letta/services/step_manager.py +++ b/letta/services/step_manager.py @@ -5,16 +5,16 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session +from letta.helpers.singleton import singleton from letta.orm.errors import NoResultFound from letta.orm.job import Job as JobModel from letta.orm.sqlalchemy_base import AccessType from letta.orm.step import Step as StepModel +from letta.otel.tracing import get_trace_id, trace_method from letta.schemas.openai.chat_completion_response import UsageStatistics from letta.schemas.step import Step as PydanticStep from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.services.helpers.noop_helper import singleton -from letta.tracing import get_trace_id, trace_method from letta.utils import enforce_types diff --git a/letta/services/summarizer/summarizer.py b/letta/services/summarizer/summarizer.py index 3fc23dbf..fa6d18d5 100644 --- a/letta/services/summarizer/summarizer.py +++ b/letta/services/summarizer/summarizer.py @@ -6,11 +6,11 @@ from typing import List, Optional, Tuple, Union from letta.agents.ephemeral_summary_agent import EphemeralSummaryAgent from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.schemas.enums import MessageRole from letta.schemas.letta_message_content import TextContent from letta.schemas.message import Message, MessageCreate from letta.services.summarizer.enums import SummarizationMode -from letta.tracing import trace_method logger = get_logger(__name__) diff --git a/letta/services/telemetry_manager.py b/letta/services/telemetry_manager.py index 2dc14ca9..213bdb09 100644 --- a/letta/services/telemetry_manager.py +++ b/letta/services/telemetry_manager.py @@ -1,11 +1,11 @@ from letta.helpers.json_helpers import json_dumps, json_loads +from letta.helpers.singleton import singleton from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel from letta.schemas.provider_trace import ProviderTrace as PydanticProviderTrace from letta.schemas.provider_trace import ProviderTraceCreate from letta.schemas.step import Step as PydanticStep from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry -from letta.services.helpers.noop_helper import singleton from letta.utils import enforce_types diff --git a/letta/services/tool_executor/builtin_tool_executor.py b/letta/services/tool_executor/builtin_tool_executor.py new file mode 100644 index 00000000..cc320a47 --- /dev/null +++ b/letta/services/tool_executor/builtin_tool_executor.py @@ -0,0 +1,117 @@ +import json +from textwrap import shorten +from typing import Any, Dict, Literal, Optional + +from letta.constants import WEB_SEARCH_CLIP_CONTENT, WEB_SEARCH_INCLUDE_SCORE, WEB_SEARCH_SEPARATOR +from letta.otel.tracing import trace_method +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.tool_executor.tool_executor_base import ToolExecutor +from letta.settings import tool_settings + + +class LettaBuiltinToolExecutor(ToolExecutor): + """Executor for built in Letta tools.""" + + @trace_method + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + function_map = {"run_code": self.run_code, "web_search": self.web_search} + + if function_name not in function_map: + raise ValueError(f"Unknown function: {function_name}") + + # Execute the appropriate function + function_args_copy = function_args.copy() # Make a copy to avoid modifying the original + function_response = await function_map[function_name](**function_args_copy) + + return ToolExecutionResult( + status="success", + func_return=function_response, + agent_state=agent_state, + ) + + async def run_code(self, code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str: + from e2b_code_interpreter import AsyncSandbox + + if tool_settings.e2b_api_key is None: + raise ValueError("E2B_API_KEY is not set") + + sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key) + params = {"code": code} + if language != "python": + # Leave empty for python + params["language"] = language + + res = self._llm_friendly_result(await sbx.run_code(**params)) + return json.dumps(res, ensure_ascii=False) + + def _llm_friendly_result(self, res): + out = { + "results": [r.text if hasattr(r, "text") else str(r) for r in res.results], + "logs": { + "stdout": getattr(res.logs, "stdout", []), + "stderr": getattr(res.logs, "stderr", []), + }, + } + err = getattr(res, "error", None) + if err is not None: + out["error"] = err + return out + + async def web_search(agent_state: "AgentState", query: str) -> str: + """ + Search the web for information. + Args: + query (str): The query to search the web for. + Returns: + str: The search results. + """ + + try: + from tavily import AsyncTavilyClient + except ImportError: + raise ImportError("tavily is not installed in the tool execution environment") + + # Check if the API key exists + if tool_settings.tavily_api_key is None: + raise ValueError("TAVILY_API_KEY is not set") + + # Instantiate client and search + tavily_client = AsyncTavilyClient(api_key=tool_settings.tavily_api_key) + search_results = await tavily_client.search(query=query, auto_parameters=True) + + results = search_results.get("results", []) + if not results: + return "No search results found." + + # ---- format for the LLM ------------------------------------------------- + formatted_blocks = [] + for idx, item in enumerate(results, start=1): + title = item.get("title") or "Untitled" + url = item.get("url") or "Unknown URL" + # keep each content snippet reasonably short so you don’t blow up context + content = ( + shorten(item.get("content", "").strip(), width=600, placeholder=" …") + if WEB_SEARCH_CLIP_CONTENT + else item.get("content", "").strip() + ) + score = item.get("score") + if WEB_SEARCH_INCLUDE_SCORE: + block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n" + else: + block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n" + formatted_blocks.append(block) + + return WEB_SEARCH_SEPARATOR.join(formatted_blocks) diff --git a/letta/services/tool_executor/composio_tool_executor.py b/letta/services/tool_executor/composio_tool_executor.py new file mode 100644 index 00000000..8053c521 --- /dev/null +++ b/letta/services/tool_executor/composio_tool_executor.py @@ -0,0 +1,53 @@ +from typing import Any, Dict, Optional + +from letta.constants import COMPOSIO_ENTITY_ENV_VAR_KEY +from letta.functions.composio_helpers import execute_composio_action_async, generate_composio_action_from_func_name +from letta.helpers.composio_helpers import get_composio_api_key_async +from letta.otel.tracing import trace_method +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.tool_executor.tool_executor_base import ToolExecutor + + +class ExternalComposioToolExecutor(ToolExecutor): + """Executor for external Composio tools.""" + + @trace_method + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + assert agent_state is not None, "Agent state is required for external Composio tools" + action_name = generate_composio_action_from_func_name(tool.name) + + # Get entity ID from the agent_state + entity_id = self._get_entity_id(agent_state) + + # Get composio_api_key + composio_api_key = await get_composio_api_key_async(actor=actor) + + # TODO (matt): Roll in execute_composio_action into this class + function_response = await execute_composio_action_async( + action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id + ) + + return ToolExecutionResult( + status="success", + func_return=function_response, + ) + + def _get_entity_id(self, agent_state: AgentState) -> Optional[str]: + """Extract the entity ID from environment variables.""" + for env_var in agent_state.tool_exec_environment_variables: + if env_var.key == COMPOSIO_ENTITY_ENV_VAR_KEY: + return env_var.value + return None diff --git a/letta/services/tool_executor/core_tool_executor.py b/letta/services/tool_executor/core_tool_executor.py new file mode 100644 index 00000000..fddd4c6f --- /dev/null +++ b/letta/services/tool_executor/core_tool_executor.py @@ -0,0 +1,474 @@ +import math +from typing import Any, Dict, Optional + +from letta.constants import ( + CORE_MEMORY_LINE_NUMBER_WARNING, + MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX, + READ_ONLY_BLOCK_EDIT_ERROR, + RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE, +) +from letta.helpers.json_helpers import json_dumps +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.agent_manager import AgentManager +from letta.services.message_manager import MessageManager +from letta.services.passage_manager import PassageManager +from letta.services.tool_executor.tool_executor_base import ToolExecutor +from letta.utils import get_friendly_error_msg + + +class LettaCoreToolExecutor(ToolExecutor): + """Executor for LETTA core tools with direct implementation of functions.""" + + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + # Map function names to method calls + assert agent_state is not None, "Agent state is required for core tools" + function_map = { + "send_message": self.send_message, + "conversation_search": self.conversation_search, + "archival_memory_search": self.archival_memory_search, + "archival_memory_insert": self.archival_memory_insert, + "core_memory_append": self.core_memory_append, + "core_memory_replace": self.core_memory_replace, + "memory_replace": self.memory_replace, + "memory_insert": self.memory_insert, + "memory_rethink": self.memory_rethink, + "memory_finish_edits": self.memory_finish_edits, + } + + if function_name not in function_map: + raise ValueError(f"Unknown function: {function_name}") + + # Execute the appropriate function + function_args_copy = function_args.copy() # Make a copy to avoid modifying the original + try: + function_response = await function_map[function_name](agent_state, actor, **function_args_copy) + return ToolExecutionResult( + status="success", + func_return=function_response, + agent_state=agent_state, + ) + except Exception as e: + return ToolExecutionResult( + status="error", + func_return=e, + agent_state=agent_state, + stderr=[get_friendly_error_msg(function_name=function_name, exception_name=type(e).__name__, exception_message=str(e))], + ) + + async def send_message(self, agent_state: AgentState, actor: User, message: str) -> Optional[str]: + """ + Sends a message to the human user. + + Args: + message (str): Message contents. All unicode (including emojis) are supported. + + Returns: + Optional[str]: None is always returned as this function does not produce a response. + """ + return "Sent message successfully." + + async def conversation_search(self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0) -> Optional[str]: + """ + Search prior conversation history using case-insensitive string matching. + + Args: + query (str): String to search for. + page (int): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page). + + Returns: + str: Query result string + """ + if page is None or (isinstance(page, str) and page.lower().strip() == "none"): + page = 0 + try: + page = int(page) + except: + raise ValueError(f"'page' argument must be an integer") + + count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE + messages = await MessageManager().list_user_messages_for_agent_async( + agent_id=agent_state.id, + actor=actor, + query_text=query, + limit=count, + ) + + total = len(messages) + num_pages = math.ceil(total / count) - 1 # 0 index + + if len(messages) == 0: + results_str = f"No results found." + else: + results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):" + results_formatted = [message.content[0].text for message in messages] + results_str = f"{results_pref} {json_dumps(results_formatted)}" + + return results_str + + async def archival_memory_search( + self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0, start: Optional[int] = 0 + ) -> Optional[str]: + """ + Search archival memory using semantic (embedding-based) search. + + Args: + query (str): String to search for. + page (Optional[int]): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page). + start (Optional[int]): Starting index for the search results. Defaults to 0. + + Returns: + str: Query result string + """ + if page is None or (isinstance(page, str) and page.lower().strip() == "none"): + page = 0 + try: + page = int(page) + except: + raise ValueError(f"'page' argument must be an integer") + + count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE + + try: + # Get results using passage manager + all_results = await AgentManager().list_passages_async( + actor=actor, + agent_id=agent_state.id, + query_text=query, + limit=count + start, # Request enough results to handle offset + embedding_config=agent_state.embedding_config, + embed_query=True, + ) + + # Apply pagination + end = min(count + start, len(all_results)) + paged_results = all_results[start:end] + + # Format results to match previous implementation + formatted_results = [{"timestamp": str(result.created_at), "content": result.text} for result in paged_results] + + return formatted_results, len(formatted_results) + + except Exception as e: + raise e + + async def archival_memory_insert(self, agent_state: AgentState, actor: User, content: str) -> Optional[str]: + """ + Add to archival memory. Make sure to phrase the memory contents such that it can be easily queried later. + + Args: + content (str): Content to write to the memory. All unicode (including emojis) are supported. + + Returns: + Optional[str]: None is always returned as this function does not produce a response. + """ + await PassageManager().insert_passage_async( + agent_state=agent_state, + agent_id=agent_state.id, + text=content, + actor=actor, + ) + await AgentManager().rebuild_system_prompt_async(agent_id=agent_state.id, actor=actor, force=True) + return None + + async def core_memory_append(self, agent_state: AgentState, actor: User, label: str, content: str) -> Optional[str]: + """ + Append to the contents of core memory. + + Args: + label (str): Section of the memory to be edited (persona or human). + content (str): Content to write to the memory. All unicode (including emojis) are supported. + + Returns: + Optional[str]: None is always returned as this function does not produce a response. + """ + if agent_state.memory.get_block(label).read_only: + raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") + current_value = str(agent_state.memory.get_block(label).value) + new_value = current_value + "\n" + str(content) + agent_state.memory.update_block_value(label=label, value=new_value) + await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) + return None + + async def core_memory_replace( + self, + agent_state: AgentState, + actor: User, + label: str, + old_content: str, + new_content: str, + ) -> Optional[str]: + """ + Replace the contents of core memory. To delete memories, use an empty string for new_content. + + Args: + label (str): Section of the memory to be edited (persona or human). + old_content (str): String to replace. Must be an exact match. + new_content (str): Content to write to the memory. All unicode (including emojis) are supported. + + Returns: + Optional[str]: None is always returned as this function does not produce a response. + """ + if agent_state.memory.get_block(label).read_only: + raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") + current_value = str(agent_state.memory.get_block(label).value) + if old_content not in current_value: + raise ValueError(f"Old content '{old_content}' not found in memory block '{label}'") + new_value = current_value.replace(str(old_content), str(new_content)) + agent_state.memory.update_block_value(label=label, value=new_value) + await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) + return None + + async def memory_replace( + self, + agent_state: AgentState, + actor: User, + label: str, + old_str: str, + new_str: Optional[str] = None, + ) -> str: + """ + The memory_replace command allows you to replace a specific string in a memory + block with a new string. This is used for making precise edits. + + Args: + label (str): Section of the memory to be edited, identified by its label. + old_str (str): The text to replace (must match exactly, including whitespace + and indentation). Do not include line number prefixes. + new_str (Optional[str]): The new text to insert in place of the old text. + Omit this argument to delete the old_str. Do not include line number prefixes. + + Returns: + str: The success message + """ + + if agent_state.memory.get_block(label).read_only: + raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") + + if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(old_str)): + raise ValueError( + "old_str contains a line number prefix, which is not allowed. " + "Do not include line numbers when calling memory tools (line " + "numbers are for display purposes only)." + ) + if CORE_MEMORY_LINE_NUMBER_WARNING in old_str: + raise ValueError( + "old_str contains a line number warning, which is not allowed. " + "Do not include line number information when calling memory tools " + "(line numbers are for display purposes only)." + ) + if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)): + raise ValueError( + "new_str contains a line number prefix, which is not allowed. " + "Do not include line numbers when calling memory tools (line " + "numbers are for display purposes only)." + ) + + old_str = str(old_str).expandtabs() + new_str = str(new_str).expandtabs() + current_value = str(agent_state.memory.get_block(label).value).expandtabs() + + # Check if old_str is unique in the block + occurences = current_value.count(old_str) + if occurences == 0: + raise ValueError( + f"No replacement was performed, old_str `{old_str}` did not appear " f"verbatim in memory block with label `{label}`." + ) + elif occurences > 1: + content_value_lines = current_value.split("\n") + lines = [idx + 1 for idx, line in enumerate(content_value_lines) if old_str in line] + raise ValueError( + f"No replacement was performed. Multiple occurrences of " + f"old_str `{old_str}` in lines {lines}. Please ensure it is unique." + ) + + # Replace old_str with new_str + new_value = current_value.replace(str(old_str), str(new_str)) + + # Write the new content to the block + agent_state.memory.update_block_value(label=label, value=new_value) + + await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) + + # Create a snippet of the edited section + SNIPPET_LINES = 3 + replacement_line = current_value.split(old_str)[0].count("\n") + start_line = max(0, replacement_line - SNIPPET_LINES) + end_line = replacement_line + SNIPPET_LINES + new_str.count("\n") + snippet = "\n".join(new_value.split("\n")[start_line : end_line + 1]) + + # Prepare the success message + success_msg = f"The core memory block with label `{label}` has been edited. " + # success_msg += self._make_output( + # snippet, f"a snippet of {path}", start_line + 1 + # ) + # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" + success_msg += ( + "Review the changes and make sure they are as expected (correct indentation, " + "no duplicate lines, etc). Edit the memory block again if necessary." + ) + + # return None + return success_msg + + async def memory_insert( + self, + agent_state: AgentState, + actor: User, + label: str, + new_str: str, + insert_line: int = -1, + ) -> str: + """ + The memory_insert command allows you to insert text at a specific location + in a memory block. + + Args: + label (str): Section of the memory to be edited, identified by its label. + new_str (str): The text to insert. Do not include line number prefixes. + insert_line (int): The line number after which to insert the text (0 for + beginning of file). Defaults to -1 (end of the file). + + Returns: + str: The success message + """ + + if agent_state.memory.get_block(label).read_only: + raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") + + if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)): + raise ValueError( + "new_str contains a line number prefix, which is not allowed. Do not " + "include line numbers when calling memory tools (line numbers are for " + "display purposes only)." + ) + if CORE_MEMORY_LINE_NUMBER_WARNING in new_str: + raise ValueError( + "new_str contains a line number warning, which is not allowed. Do not " + "include line number information when calling memory tools (line numbers " + "are for display purposes only)." + ) + + current_value = str(agent_state.memory.get_block(label).value).expandtabs() + new_str = str(new_str).expandtabs() + current_value_lines = current_value.split("\n") + n_lines = len(current_value_lines) + + # Check if we're in range, from 0 (pre-line), to 1 (first line), to n_lines (last line) + if insert_line == -1: + insert_line = n_lines + elif insert_line < 0 or insert_line > n_lines: + raise ValueError( + f"Invalid `insert_line` parameter: {insert_line}. It should be within " + f"the range of lines of the memory block: {[0, n_lines]}, or -1 to " + f"append to the end of the memory block." + ) + + # Insert the new string as a line + SNIPPET_LINES = 3 + new_str_lines = new_str.split("\n") + new_value_lines = current_value_lines[:insert_line] + new_str_lines + current_value_lines[insert_line:] + snippet_lines = ( + current_value_lines[max(0, insert_line - SNIPPET_LINES) : insert_line] + + new_str_lines + + current_value_lines[insert_line : insert_line + SNIPPET_LINES] + ) + + # Collate into the new value to update + new_value = "\n".join(new_value_lines) + snippet = "\n".join(snippet_lines) + + # Write into the block + agent_state.memory.update_block_value(label=label, value=new_value) + + await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) + + # Prepare the success message + success_msg = f"The core memory block with label `{label}` has been edited. " + # success_msg += self._make_output( + # snippet, + # "a snippet of the edited file", + # max(1, insert_line - SNIPPET_LINES + 1), + # ) + # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" + success_msg += ( + "Review the changes and make sure they are as expected (correct indentation, " + "no duplicate lines, etc). Edit the memory block again if necessary." + ) + + return success_msg + + async def memory_rethink(self, agent_state: AgentState, actor: User, label: str, new_memory: str) -> str: + """ + The memory_rethink command allows you to completely rewrite the contents of a + memory block. Use this tool to make large sweeping changes (e.g. when you want + to condense or reorganize the memory blocks), do NOT use this tool to make small + precise edits (e.g. add or remove a line, replace a specific string, etc). + + Args: + label (str): The memory block to be rewritten, identified by its label. + new_memory (str): The new memory contents with information integrated from + existing memory blocks and the conversation context. Do not include line number prefixes. + + Returns: + str: The success message + """ + if agent_state.memory.get_block(label).read_only: + raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") + + if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_memory)): + raise ValueError( + "new_memory contains a line number prefix, which is not allowed. Do not " + "include line numbers when calling memory tools (line numbers are for " + "display purposes only)." + ) + if CORE_MEMORY_LINE_NUMBER_WARNING in new_memory: + raise ValueError( + "new_memory contains a line number warning, which is not allowed. Do not " + "include line number information when calling memory tools (line numbers " + "are for display purposes only)." + ) + + if agent_state.memory.get_block(label) is None: + agent_state.memory.create_block(label=label, value=new_memory) + + agent_state.memory.update_block_value(label=label, value=new_memory) + + await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) + + # Prepare the success message + success_msg = f"The core memory block with label `{label}` has been edited. " + # success_msg += self._make_output( + # snippet, f"a snippet of {path}", start_line + 1 + # ) + # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" + success_msg += ( + "Review the changes and make sure they are as expected (correct indentation, " + "no duplicate lines, etc). Edit the memory block again if necessary." + ) + + # return None + return success_msg + + async def memory_finish_edits(self, agent_state: AgentState, actor: User) -> None: + """ + Call the memory_finish_edits command when you are finished making edits + (integrating all new information) into the memory blocks. This function + is called when the agent is done rethinking the memory. + + Returns: + Optional[str]: None is always returned as this function does not produce a response. + """ + return None diff --git a/letta/services/tool_executor/files_tool_executor.py b/letta/services/tool_executor/files_tool_executor.py new file mode 100644 index 00000000..15d8f00a --- /dev/null +++ b/letta/services/tool_executor/files_tool_executor.py @@ -0,0 +1,131 @@ +from typing import Any, Dict, List, Optional, Tuple + +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.agent_manager import AgentManager +from letta.services.block_manager import BlockManager +from letta.services.file_processor.chunker.line_chunker import LineChunker +from letta.services.files_agents_manager import FileAgentManager +from letta.services.message_manager import MessageManager +from letta.services.passage_manager import PassageManager +from letta.services.source_manager import SourceManager +from letta.services.tool_executor.tool_executor_base import ToolExecutor +from letta.utils import get_friendly_error_msg + + +class LettaFileToolExecutor(ToolExecutor): + """Executor for Letta file tools with direct implementation of functions.""" + + def __init__( + self, + message_manager: MessageManager, + agent_manager: AgentManager, + block_manager: BlockManager, + passage_manager: PassageManager, + actor: User, + ): + super().__init__( + message_manager=message_manager, + agent_manager=agent_manager, + block_manager=block_manager, + passage_manager=passage_manager, + actor=actor, + ) + + # TODO: This should be passed in to for testing purposes + self.files_agents_manager = FileAgentManager() + self.source_manager = SourceManager() + + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + if agent_state is None: + raise ValueError("Agent state is required for file tools") + + function_map = { + "open_file": self.open_file, + "close_file": self.close_file, + "grep": self.grep, + "search_files": self.search_files, + } + + if function_name not in function_map: + raise ValueError(f"Unknown function: {function_name}") + + function_args_copy = function_args.copy() + try: + func_return = await function_map[function_name](agent_state, **function_args_copy) + return ToolExecutionResult( + status="success", + func_return=func_return, + agent_state=agent_state, + ) + except Exception as e: + return ToolExecutionResult( + status="error", + func_return=e, + agent_state=agent_state, + stderr=[get_friendly_error_msg(function_name=function_name, exception_name=type(e).__name__, exception_message=str(e))], + ) + + async def open_file(self, agent_state: AgentState, file_name: str, view_range: Optional[Tuple[int, int]] = None) -> str: + """Stub for open_file tool.""" + start, end = None, None + if view_range: + start, end = view_range + if start >= end: + raise ValueError(f"Provided view range {view_range} is invalid, starting range must be less than ending range.") + + # TODO: This is inefficient. We can skip the initial DB lookup by preserving on the block metadata what the file_id is + file_agent = await self.files_agents_manager.get_file_agent_by_file_name( + agent_id=agent_state.id, file_name=file_name, actor=self.actor + ) + + if not file_agent: + file_blocks = agent_state.memory.file_blocks + file_names = [fb.label for fb in file_blocks] + raise ValueError( + f"{file_name} not attached - did you get the filename correct? Currently you have the following files attached: {file_names}" + ) + + file_id = file_agent.file_id + file = await self.source_manager.get_file_by_id(file_id=file_id, actor=self.actor, include_content=True) + + # TODO: Inefficient, maybe we can pre-compute this + # TODO: This is also not the best way to split things - would be cool to have "content aware" splitting + # TODO: Split code differently from large text blurbs + content_lines = LineChunker().chunk_text(text=file.content, start=start, end=end) + visible_content = "\n".join(content_lines) + + await self.files_agents_manager.update_file_agent_by_id( + agent_id=agent_state.id, file_id=file_id, actor=self.actor, is_open=True, visible_content=visible_content + ) + + return "Success" + + async def close_file(self, agent_state: AgentState, file_name: str) -> str: + """Stub for close_file tool.""" + await self.files_agents_manager.update_file_agent_by_name( + agent_id=agent_state.id, file_name=file_name, actor=self.actor, is_open=False + ) + return "Success" + + async def grep(self, agent_state: AgentState, pattern: str) -> str: + """Stub for grep tool.""" + raise NotImplementedError + + # TODO: Make this paginated? + async def search_files(self, agent_state: AgentState, query: str) -> List[str]: + """Stub for search_files tool.""" + passages = await self.agent_manager.list_source_passages_async(actor=self.actor, agent_id=agent_state.id, query_text=query) + return [p.text for p in passages] diff --git a/letta/services/tool_executor/mcp_tool_executor.py b/letta/services/tool_executor/mcp_tool_executor.py new file mode 100644 index 00000000..1640c145 --- /dev/null +++ b/letta/services/tool_executor/mcp_tool_executor.py @@ -0,0 +1,45 @@ +from typing import Any, Dict, Optional + +from letta.constants import MCP_TOOL_TAG_NAME_PREFIX +from letta.otel.tracing import trace_method +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.mcp_manager import MCPManager +from letta.services.tool_executor.tool_executor_base import ToolExecutor + + +class ExternalMCPToolExecutor(ToolExecutor): + """Executor for external MCP tools.""" + + @trace_method + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + + pass + + mcp_server_tag = [tag for tag in tool.tags if tag.startswith(f"{MCP_TOOL_TAG_NAME_PREFIX}:")] + if not mcp_server_tag: + raise ValueError(f"Tool {tool.name} does not have a valid MCP server tag") + mcp_server_name = mcp_server_tag[0].split(":")[1] + + mcp_manager = MCPManager() + # TODO: may need to have better client connection management + function_response, success = await mcp_manager.execute_mcp_server_tool( + mcp_server_name=mcp_server_name, tool_name=function_name, tool_args=function_args, actor=actor + ) + + return ToolExecutionResult( + status="success" if success else "error", + func_return=function_response, + ) diff --git a/letta/services/tool_executor/multi_agent_tool_executor.py b/letta/services/tool_executor/multi_agent_tool_executor.py new file mode 100644 index 00000000..02accc2e --- /dev/null +++ b/letta/services/tool_executor/multi_agent_tool_executor.py @@ -0,0 +1,123 @@ +import asyncio +from typing import Any, Dict, List, Optional + +from letta.schemas.agent import AgentState +from letta.schemas.enums import MessageRole +from letta.schemas.letta_message import AssistantMessage +from letta.schemas.letta_message_content import TextContent +from letta.schemas.message import MessageCreate +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.tool_executor.tool_executor import logger +from letta.services.tool_executor.tool_executor_base import ToolExecutor + + +class LettaMultiAgentToolExecutor(ToolExecutor): + """Executor for LETTA multi-agent core tools.""" + + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + assert agent_state is not None, "Agent state is required for multi-agent tools" + function_map = { + "send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply, + "send_message_to_agent_async": self.send_message_to_agent_async, + "send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async, + } + + if function_name not in function_map: + raise ValueError(f"Unknown function: {function_name}") + + # Execute the appropriate function + function_args_copy = function_args.copy() # Make a copy to avoid modifying the original + function_response = await function_map[function_name](agent_state, **function_args_copy) + return ToolExecutionResult( + status="success", + func_return=function_response, + ) + + async def send_message_to_agent_and_wait_for_reply(self, agent_state: AgentState, message: str, other_agent_id: str) -> str: + augmented_message = ( + f"[Incoming message from agent with ID '{agent_state.id}' - to reply to this message, " + f"make sure to use the 'send_message' at the end, and the system will notify the sender of your response] " + f"{message}" + ) + + return str(await self._process_agent(agent_id=other_agent_id, message=augmented_message)) + + async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str: + # 1) Build the prefixed system‐message + prefixed = ( + f"[Incoming message from agent with ID '{agent_state.id}' - " + f"to reply to this message, make sure to use the " + f"'send_message_to_agent_async' tool, or the agent will not receive your message] " + f"{message}" + ) + + task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed)) + + task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None)) + + return "Successfully sent message" + + async def send_message_to_agents_matching_tags_async( + self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str] + ) -> str: + # Find matching agents + matching_agents = await self.agent_manager.list_agents_matching_tags_async( + actor=self.actor, match_all=match_all, match_some=match_some + ) + if not matching_agents: + return str([]) + + augmented_message = ( + "[Incoming message from external Letta agent - to reply to this message, " + "make sure to use the 'send_message' at the end, and the system will notify " + "the sender of your response] " + f"{message}" + ) + + tasks = [ + asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents + ] + results = await asyncio.gather(*tasks) + return str(results) + + async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]: + from letta.agents.letta_agent import LettaAgent + + try: + letta_agent = LettaAgent( + agent_id=agent_id, + message_manager=self.message_manager, + agent_manager=self.agent_manager, + block_manager=self.block_manager, + passage_manager=self.passage_manager, + actor=self.actor, + ) + + letta_response = await letta_agent.step([MessageCreate(role=MessageRole.system, content=[TextContent(text=message)])]) + messages = letta_response.messages + + send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)] + + return { + "agent_id": agent_id, + "response": send_message_content if send_message_content else [""], + } + + except Exception as e: + return { + "agent_id": agent_id, + "error": str(e), + "type": type(e).__name__, + } diff --git a/letta/services/tool_executor/tool_execution_manager.py b/letta/services/tool_executor/tool_execution_manager.py index f8aa622e..2b4dad6a 100644 --- a/letta/services/tool_executor/tool_execution_manager.py +++ b/letta/services/tool_executor/tool_execution_manager.py @@ -2,8 +2,12 @@ import traceback from typing import Any, Dict, Optional, Type from letta.constants import FUNCTION_RETURN_VALUE_TRUNCATED +from letta.helpers.datetime_helpers import AsyncTimer from letta.log import get_logger from letta.orm.enums import ToolType +from letta.otel.context import get_ctx_attributes +from letta.otel.metric_registry import MetricRegistry +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState from letta.schemas.sandbox_config import SandboxConfig from letta.schemas.tool import Tool @@ -13,16 +17,14 @@ from letta.services.agent_manager import AgentManager from letta.services.block_manager import BlockManager from letta.services.message_manager import MessageManager from letta.services.passage_manager import PassageManager -from letta.services.tool_executor.tool_executor import ( - ExternalComposioToolExecutor, - ExternalMCPToolExecutor, - LettaBuiltinToolExecutor, - LettaCoreToolExecutor, - LettaMultiAgentToolExecutor, - SandboxToolExecutor, - ToolExecutor, -) -from letta.tracing import trace_method +from letta.services.tool_executor.builtin_tool_executor import LettaBuiltinToolExecutor +from letta.services.tool_executor.composio_tool_executor import ExternalComposioToolExecutor +from letta.services.tool_executor.core_tool_executor import LettaCoreToolExecutor +from letta.services.tool_executor.files_tool_executor import LettaFileToolExecutor +from letta.services.tool_executor.mcp_tool_executor import ExternalMCPToolExecutor +from letta.services.tool_executor.multi_agent_tool_executor import LettaMultiAgentToolExecutor +from letta.services.tool_executor.tool_executor import SandboxToolExecutor +from letta.services.tool_executor.tool_executor_base import ToolExecutor from letta.utils import get_friendly_error_msg @@ -35,6 +37,7 @@ class ToolExecutorFactory: ToolType.LETTA_SLEEPTIME_CORE: LettaCoreToolExecutor, ToolType.LETTA_MULTI_AGENT_CORE: LettaMultiAgentToolExecutor, ToolType.LETTA_BUILTIN: LettaBuiltinToolExecutor, + ToolType.LETTA_FILES_CORE: LettaFileToolExecutor, ToolType.EXTERNAL_COMPOSIO: ExternalComposioToolExecutor, ToolType.EXTERNAL_MCP: ExternalMCPToolExecutor, } @@ -85,10 +88,13 @@ class ToolExecutionManager: self.sandbox_env_vars = sandbox_env_vars @trace_method - async def execute_tool_async(self, function_name: str, function_args: dict, tool: Tool) -> ToolExecutionResult: + async def execute_tool_async( + self, function_name: str, function_args: dict, tool: Tool, step_id: str | None = None + ) -> ToolExecutionResult: """ Execute a tool asynchronously and persist any state changes. """ + status = "error" # set as default for tracking purposes try: executor = ToolExecutorFactory.get_executor( tool.tool_type, @@ -98,9 +104,17 @@ class ToolExecutionManager: passage_manager=self.passage_manager, actor=self.actor, ) - result = await executor.execute( - function_name, function_args, tool, self.actor, self.agent_state, self.sandbox_config, self.sandbox_env_vars - ) + + def _metrics_callback(exec_time_ms: int, exc): + return MetricRegistry().tool_execution_time_ms_histogram.record( + exec_time_ms, dict(get_ctx_attributes(), **{"tool.name": tool.name}) + ) + + async with AsyncTimer(callback_func=_metrics_callback): + result = await executor.execute( + function_name, function_args, tool, self.actor, self.agent_state, self.sandbox_config, self.sandbox_env_vars + ) + status = result.status # trim result return_str = str(result.func_return) @@ -110,6 +124,7 @@ class ToolExecutionManager: return result except Exception as e: + status = "error" self.logger.error(f"Error executing tool {function_name}: {str(e)}") error_message = get_friendly_error_msg( function_name=function_name, @@ -121,3 +136,8 @@ class ToolExecutionManager: func_return=error_message, stderr=[traceback.format_exc()], ) + finally: + metric_attrs = {"tool.name": tool.name, "tool.execution_success": status == "success"} + if status == "error" and step_id: + metric_attrs["step.id"] = step_id + MetricRegistry().tool_execution_counter.add(1, dict(get_ctx_attributes(), **metric_attrs)) diff --git a/letta/services/tool_executor/tool_execution_sandbox.py b/letta/services/tool_executor/tool_execution_sandbox.py index 4d60de8f..e41089ea 100644 --- a/letta/services/tool_executor/tool_execution_sandbox.py +++ b/letta/services/tool_executor/tool_execution_sandbox.py @@ -11,6 +11,7 @@ from typing import Any, Dict, Optional from letta.functions.helpers import generate_model_from_args_json_schema from letta.log import get_logger +from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState from letta.schemas.sandbox_config import SandboxConfig, SandboxType from letta.schemas.tool import Tool @@ -27,7 +28,6 @@ from letta.services.organization_manager import OrganizationManager from letta.services.sandbox_config_manager import SandboxConfigManager from letta.services.tool_manager import ToolManager from letta.settings import tool_settings -from letta.tracing import log_event, trace_method from letta.utils import get_friendly_error_msg logger = get_logger(__name__) diff --git a/letta/services/tool_executor/tool_executor.py b/letta/services/tool_executor/tool_executor.py index 7e970146..b57d87cb 100644 --- a/letta/services/tool_executor/tool_executor.py +++ b/letta/services/tool_executor/tool_executor.py @@ -1,720 +1,25 @@ -import asyncio -import json -import math import traceback -from abc import ABC, abstractmethod -from textwrap import shorten -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, Optional -from letta.constants import ( - COMPOSIO_ENTITY_ENV_VAR_KEY, - CORE_MEMORY_LINE_NUMBER_WARNING, - MCP_TOOL_TAG_NAME_PREFIX, - MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX, - READ_ONLY_BLOCK_EDIT_ERROR, - RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE, - WEB_SEARCH_CLIP_CONTENT, - WEB_SEARCH_INCLUDE_SCORE, - WEB_SEARCH_SEPARATOR, -) from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_function_annotations_from_source -from letta.functions.composio_helpers import execute_composio_action_async, generate_composio_action_from_func_name -from letta.helpers.composio_helpers import get_composio_api_key_async -from letta.helpers.json_helpers import json_dumps from letta.log import get_logger +from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState -from letta.schemas.enums import MessageRole -from letta.schemas.letta_message import AssistantMessage -from letta.schemas.letta_message_content import TextContent -from letta.schemas.message import MessageCreate from letta.schemas.sandbox_config import SandboxConfig from letta.schemas.tool import Tool from letta.schemas.tool_execution_result import ToolExecutionResult from letta.schemas.user import User from letta.services.agent_manager import AgentManager -from letta.services.block_manager import BlockManager -from letta.services.mcp_manager import MCPManager -from letta.services.message_manager import MessageManager -from letta.services.passage_manager import PassageManager +from letta.services.tool_executor.tool_executor_base import ToolExecutor from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B from letta.services.tool_sandbox.local_sandbox import AsyncToolSandboxLocal from letta.settings import tool_settings -from letta.tracing import trace_method from letta.types import JsonDict from letta.utils import get_friendly_error_msg logger = get_logger(__name__) -class ToolExecutor(ABC): - """Abstract base class for tool executors.""" - - def __init__( - self, - message_manager: MessageManager, - agent_manager: AgentManager, - block_manager: BlockManager, - passage_manager: PassageManager, - actor: User, - ): - self.message_manager = message_manager - self.agent_manager = agent_manager - self.block_manager = block_manager - self.passage_manager = passage_manager - self.actor = actor - - @abstractmethod - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - """Execute the tool and return the result.""" - - -class LettaCoreToolExecutor(ToolExecutor): - """Executor for LETTA core tools with direct implementation of functions.""" - - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - # Map function names to method calls - assert agent_state is not None, "Agent state is required for core tools" - function_map = { - "send_message": self.send_message, - "conversation_search": self.conversation_search, - "archival_memory_search": self.archival_memory_search, - "archival_memory_insert": self.archival_memory_insert, - "core_memory_append": self.core_memory_append, - "core_memory_replace": self.core_memory_replace, - "memory_replace": self.memory_replace, - "memory_insert": self.memory_insert, - "memory_rethink": self.memory_rethink, - "memory_finish_edits": self.memory_finish_edits, - } - - if function_name not in function_map: - raise ValueError(f"Unknown function: {function_name}") - - # Execute the appropriate function - function_args_copy = function_args.copy() # Make a copy to avoid modifying the original - try: - function_response = await function_map[function_name](agent_state, actor, **function_args_copy) - return ToolExecutionResult( - status="success", - func_return=function_response, - agent_state=agent_state, - ) - except Exception as e: - return ToolExecutionResult( - status="error", - func_return=e, - agent_state=agent_state, - stderr=[get_friendly_error_msg(function_name=function_name, exception_name=type(e).__name__, exception_message=str(e))], - ) - - async def send_message(self, agent_state: AgentState, actor: User, message: str) -> Optional[str]: - """ - Sends a message to the human user. - - Args: - message (str): Message contents. All unicode (including emojis) are supported. - - Returns: - Optional[str]: None is always returned as this function does not produce a response. - """ - return "Sent message successfully." - - async def conversation_search(self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0) -> Optional[str]: - """ - Search prior conversation history using case-insensitive string matching. - - Args: - query (str): String to search for. - page (int): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page). - - Returns: - str: Query result string - """ - if page is None or (isinstance(page, str) and page.lower().strip() == "none"): - page = 0 - try: - page = int(page) - except: - raise ValueError(f"'page' argument must be an integer") - - count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE - messages = await MessageManager().list_user_messages_for_agent_async( - agent_id=agent_state.id, - actor=actor, - query_text=query, - limit=count, - ) - - total = len(messages) - num_pages = math.ceil(total / count) - 1 # 0 index - - if len(messages) == 0: - results_str = f"No results found." - else: - results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):" - results_formatted = [message.content[0].text for message in messages] - results_str = f"{results_pref} {json_dumps(results_formatted)}" - - return results_str - - async def archival_memory_search( - self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0, start: Optional[int] = 0 - ) -> Optional[str]: - """ - Search archival memory using semantic (embedding-based) search. - - Args: - query (str): String to search for. - page (Optional[int]): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page). - start (Optional[int]): Starting index for the search results. Defaults to 0. - - Returns: - str: Query result string - """ - if page is None or (isinstance(page, str) and page.lower().strip() == "none"): - page = 0 - try: - page = int(page) - except: - raise ValueError(f"'page' argument must be an integer") - - count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE - - try: - # Get results using passage manager - all_results = await AgentManager().list_passages_async( - actor=actor, - agent_id=agent_state.id, - query_text=query, - limit=count + start, # Request enough results to handle offset - embedding_config=agent_state.embedding_config, - embed_query=True, - ) - - # Apply pagination - end = min(count + start, len(all_results)) - paged_results = all_results[start:end] - - # Format results to match previous implementation - formatted_results = [{"timestamp": str(result.created_at), "content": result.text} for result in paged_results] - - return formatted_results, len(formatted_results) - - except Exception as e: - raise e - - async def archival_memory_insert(self, agent_state: AgentState, actor: User, content: str) -> Optional[str]: - """ - Add to archival memory. Make sure to phrase the memory contents such that it can be easily queried later. - - Args: - content (str): Content to write to the memory. All unicode (including emojis) are supported. - - Returns: - Optional[str]: None is always returned as this function does not produce a response. - """ - await PassageManager().insert_passage_async( - agent_state=agent_state, - agent_id=agent_state.id, - text=content, - actor=actor, - ) - await AgentManager().rebuild_system_prompt_async(agent_id=agent_state.id, actor=actor, force=True) - return None - - async def core_memory_append(self, agent_state: AgentState, actor: User, label: str, content: str) -> Optional[str]: - """ - Append to the contents of core memory. - - Args: - label (str): Section of the memory to be edited (persona or human). - content (str): Content to write to the memory. All unicode (including emojis) are supported. - - Returns: - Optional[str]: None is always returned as this function does not produce a response. - """ - if agent_state.memory.get_block(label).read_only: - raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") - current_value = str(agent_state.memory.get_block(label).value) - new_value = current_value + "\n" + str(content) - agent_state.memory.update_block_value(label=label, value=new_value) - await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) - return None - - async def core_memory_replace( - self, - agent_state: AgentState, - actor: User, - label: str, - old_content: str, - new_content: str, - ) -> Optional[str]: - """ - Replace the contents of core memory. To delete memories, use an empty string for new_content. - - Args: - label (str): Section of the memory to be edited (persona or human). - old_content (str): String to replace. Must be an exact match. - new_content (str): Content to write to the memory. All unicode (including emojis) are supported. - - Returns: - Optional[str]: None is always returned as this function does not produce a response. - """ - if agent_state.memory.get_block(label).read_only: - raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") - current_value = str(agent_state.memory.get_block(label).value) - if old_content not in current_value: - raise ValueError(f"Old content '{old_content}' not found in memory block '{label}'") - new_value = current_value.replace(str(old_content), str(new_content)) - agent_state.memory.update_block_value(label=label, value=new_value) - await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) - return None - - async def memory_replace( - self, - agent_state: AgentState, - actor: User, - label: str, - old_str: str, - new_str: Optional[str] = None, - ) -> str: - """ - The memory_replace command allows you to replace a specific string in a memory - block with a new string. This is used for making precise edits. - - Args: - label (str): Section of the memory to be edited, identified by its label. - old_str (str): The text to replace (must match exactly, including whitespace - and indentation). Do not include line number prefixes. - new_str (Optional[str]): The new text to insert in place of the old text. - Omit this argument to delete the old_str. Do not include line number prefixes. - - Returns: - str: The success message - """ - - if agent_state.memory.get_block(label).read_only: - raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") - - if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(old_str)): - raise ValueError( - "old_str contains a line number prefix, which is not allowed. " - "Do not include line numbers when calling memory tools (line " - "numbers are for display purposes only)." - ) - if CORE_MEMORY_LINE_NUMBER_WARNING in old_str: - raise ValueError( - "old_str contains a line number warning, which is not allowed. " - "Do not include line number information when calling memory tools " - "(line numbers are for display purposes only)." - ) - if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)): - raise ValueError( - "new_str contains a line number prefix, which is not allowed. " - "Do not include line numbers when calling memory tools (line " - "numbers are for display purposes only)." - ) - - old_str = str(old_str).expandtabs() - new_str = str(new_str).expandtabs() - current_value = str(agent_state.memory.get_block(label).value).expandtabs() - - # Check if old_str is unique in the block - occurences = current_value.count(old_str) - if occurences == 0: - raise ValueError( - f"No replacement was performed, old_str `{old_str}` did not appear " f"verbatim in memory block with label `{label}`." - ) - elif occurences > 1: - content_value_lines = current_value.split("\n") - lines = [idx + 1 for idx, line in enumerate(content_value_lines) if old_str in line] - raise ValueError( - f"No replacement was performed. Multiple occurrences of " - f"old_str `{old_str}` in lines {lines}. Please ensure it is unique." - ) - - # Replace old_str with new_str - new_value = current_value.replace(str(old_str), str(new_str)) - - # Write the new content to the block - agent_state.memory.update_block_value(label=label, value=new_value) - - await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) - - # Create a snippet of the edited section - SNIPPET_LINES = 3 - replacement_line = current_value.split(old_str)[0].count("\n") - start_line = max(0, replacement_line - SNIPPET_LINES) - end_line = replacement_line + SNIPPET_LINES + new_str.count("\n") - snippet = "\n".join(new_value.split("\n")[start_line : end_line + 1]) - - # Prepare the success message - success_msg = f"The core memory block with label `{label}` has been edited. " - # success_msg += self._make_output( - # snippet, f"a snippet of {path}", start_line + 1 - # ) - # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" - success_msg += ( - "Review the changes and make sure they are as expected (correct indentation, " - "no duplicate lines, etc). Edit the memory block again if necessary." - ) - - # return None - return success_msg - - async def memory_insert( - self, - agent_state: AgentState, - actor: User, - label: str, - new_str: str, - insert_line: int = -1, - ) -> str: - """ - The memory_insert command allows you to insert text at a specific location - in a memory block. - - Args: - label (str): Section of the memory to be edited, identified by its label. - new_str (str): The text to insert. Do not include line number prefixes. - insert_line (int): The line number after which to insert the text (0 for - beginning of file). Defaults to -1 (end of the file). - - Returns: - str: The success message - """ - - if agent_state.memory.get_block(label).read_only: - raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") - - if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)): - raise ValueError( - "new_str contains a line number prefix, which is not allowed. Do not " - "include line numbers when calling memory tools (line numbers are for " - "display purposes only)." - ) - if CORE_MEMORY_LINE_NUMBER_WARNING in new_str: - raise ValueError( - "new_str contains a line number warning, which is not allowed. Do not " - "include line number information when calling memory tools (line numbers " - "are for display purposes only)." - ) - - current_value = str(agent_state.memory.get_block(label).value).expandtabs() - new_str = str(new_str).expandtabs() - current_value_lines = current_value.split("\n") - n_lines = len(current_value_lines) - - # Check if we're in range, from 0 (pre-line), to 1 (first line), to n_lines (last line) - if insert_line == -1: - insert_line = n_lines - elif insert_line < 0 or insert_line > n_lines: - raise ValueError( - f"Invalid `insert_line` parameter: {insert_line}. It should be within " - f"the range of lines of the memory block: {[0, n_lines]}, or -1 to " - f"append to the end of the memory block." - ) - - # Insert the new string as a line - SNIPPET_LINES = 3 - new_str_lines = new_str.split("\n") - new_value_lines = current_value_lines[:insert_line] + new_str_lines + current_value_lines[insert_line:] - snippet_lines = ( - current_value_lines[max(0, insert_line - SNIPPET_LINES) : insert_line] - + new_str_lines - + current_value_lines[insert_line : insert_line + SNIPPET_LINES] - ) - - # Collate into the new value to update - new_value = "\n".join(new_value_lines) - snippet = "\n".join(snippet_lines) - - # Write into the block - agent_state.memory.update_block_value(label=label, value=new_value) - - await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) - - # Prepare the success message - success_msg = f"The core memory block with label `{label}` has been edited. " - # success_msg += self._make_output( - # snippet, - # "a snippet of the edited file", - # max(1, insert_line - SNIPPET_LINES + 1), - # ) - # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" - success_msg += ( - "Review the changes and make sure they are as expected (correct indentation, " - "no duplicate lines, etc). Edit the memory block again if necessary." - ) - - return success_msg - - async def memory_rethink(self, agent_state: AgentState, actor: User, label: str, new_memory: str) -> str: - """ - The memory_rethink command allows you to completely rewrite the contents of a - memory block. Use this tool to make large sweeping changes (e.g. when you want - to condense or reorganize the memory blocks), do NOT use this tool to make small - precise edits (e.g. add or remove a line, replace a specific string, etc). - - Args: - label (str): The memory block to be rewritten, identified by its label. - new_memory (str): The new memory contents with information integrated from - existing memory blocks and the conversation context. Do not include line number prefixes. - - Returns: - str: The success message - """ - if agent_state.memory.get_block(label).read_only: - raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}") - - if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_memory)): - raise ValueError( - "new_memory contains a line number prefix, which is not allowed. Do not " - "include line numbers when calling memory tools (line numbers are for " - "display purposes only)." - ) - if CORE_MEMORY_LINE_NUMBER_WARNING in new_memory: - raise ValueError( - "new_memory contains a line number warning, which is not allowed. Do not " - "include line number information when calling memory tools (line numbers " - "are for display purposes only)." - ) - - if agent_state.memory.get_block(label) is None: - agent_state.memory.create_block(label=label, value=new_memory) - - agent_state.memory.update_block_value(label=label, value=new_memory) - - await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor) - - # Prepare the success message - success_msg = f"The core memory block with label `{label}` has been edited. " - # success_msg += self._make_output( - # snippet, f"a snippet of {path}", start_line + 1 - # ) - # success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n" - success_msg += ( - "Review the changes and make sure they are as expected (correct indentation, " - "no duplicate lines, etc). Edit the memory block again if necessary." - ) - - # return None - return success_msg - - async def memory_finish_edits(self, agent_state: AgentState, actor: User) -> None: - """ - Call the memory_finish_edits command when you are finished making edits - (integrating all new information) into the memory blocks. This function - is called when the agent is done rethinking the memory. - - Returns: - Optional[str]: None is always returned as this function does not produce a response. - """ - return None - - -class LettaMultiAgentToolExecutor(ToolExecutor): - """Executor for LETTA multi-agent core tools.""" - - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - assert agent_state is not None, "Agent state is required for multi-agent tools" - function_map = { - "send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply, - "send_message_to_agent_async": self.send_message_to_agent_async, - "send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async, - } - - if function_name not in function_map: - raise ValueError(f"Unknown function: {function_name}") - - # Execute the appropriate function - function_args_copy = function_args.copy() # Make a copy to avoid modifying the original - function_response = await function_map[function_name](agent_state, **function_args_copy) - return ToolExecutionResult( - status="success", - func_return=function_response, - ) - - async def send_message_to_agent_and_wait_for_reply(self, agent_state: AgentState, message: str, other_agent_id: str) -> str: - augmented_message = ( - f"[Incoming message from agent with ID '{agent_state.id}' - to reply to this message, " - f"make sure to use the 'send_message' at the end, and the system will notify the sender of your response] " - f"{message}" - ) - - return str(await self._process_agent(agent_id=other_agent_id, message=augmented_message)) - - async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str: - # 1) Build the prefixed system‐message - prefixed = ( - f"[Incoming message from agent with ID '{agent_state.id}' - " - f"to reply to this message, make sure to use the " - f"'send_message_to_agent_async' tool, or the agent will not receive your message] " - f"{message}" - ) - - task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed)) - - task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None)) - - return "Successfully sent message" - - async def send_message_to_agents_matching_tags_async( - self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str] - ) -> str: - # Find matching agents - matching_agents = await self.agent_manager.list_agents_matching_tags_async( - actor=self.actor, match_all=match_all, match_some=match_some - ) - if not matching_agents: - return str([]) - - augmented_message = ( - "[Incoming message from external Letta agent - to reply to this message, " - "make sure to use the 'send_message' at the end, and the system will notify " - "the sender of your response] " - f"{message}" - ) - - tasks = [ - asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents - ] - results = await asyncio.gather(*tasks) - return str(results) - - async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]: - from letta.agents.letta_agent import LettaAgent - - try: - letta_agent = LettaAgent( - agent_id=agent_id, - message_manager=self.message_manager, - agent_manager=self.agent_manager, - block_manager=self.block_manager, - passage_manager=self.passage_manager, - actor=self.actor, - ) - - letta_response = await letta_agent.step([MessageCreate(role=MessageRole.system, content=[TextContent(text=message)])]) - messages = letta_response.messages - - send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)] - - return { - "agent_id": agent_id, - "response": send_message_content if send_message_content else [""], - } - - except Exception as e: - return { - "agent_id": agent_id, - "error": str(e), - "type": type(e).__name__, - } - - -class ExternalComposioToolExecutor(ToolExecutor): - """Executor for external Composio tools.""" - - @trace_method - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - assert agent_state is not None, "Agent state is required for external Composio tools" - action_name = generate_composio_action_from_func_name(tool.name) - - # Get entity ID from the agent_state - entity_id = self._get_entity_id(agent_state) - - # Get composio_api_key - composio_api_key = await get_composio_api_key_async(actor=actor) - - # TODO (matt): Roll in execute_composio_action into this class - function_response = await execute_composio_action_async( - action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id - ) - - return ToolExecutionResult( - status="success", - func_return=function_response, - ) - - def _get_entity_id(self, agent_state: AgentState) -> Optional[str]: - """Extract the entity ID from environment variables.""" - for env_var in agent_state.tool_exec_environment_variables: - if env_var.key == COMPOSIO_ENTITY_ENV_VAR_KEY: - return env_var.value - return None - - -class ExternalMCPToolExecutor(ToolExecutor): - """Executor for external MCP tools.""" - - @trace_method - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - - pass - - mcp_server_tag = [tag for tag in tool.tags if tag.startswith(f"{MCP_TOOL_TAG_NAME_PREFIX}:")] - if not mcp_server_tag: - raise ValueError(f"Tool {tool.name} does not have a valid MCP server tag") - mcp_server_name = mcp_server_tag[0].split(":")[1] - - mcp_manager = MCPManager() - # TODO: may need to have better client connection management - function_response, success = await mcp_manager.execute_mcp_server_tool( - mcp_server_name=mcp_server_name, tool_name=function_name, tool_args=function_args, actor=actor - ) - - return ToolExecutionResult( - status="success" if success else "error", - func_return=function_response, - ) - - class SandboxToolExecutor(ToolExecutor): """Executor for sandboxed tools.""" @@ -801,107 +106,3 @@ class SandboxToolExecutor(ToolExecutor): func_return=error_message, stderr=[stderr], ) - - -class LettaBuiltinToolExecutor(ToolExecutor): - """Executor for built in Letta tools.""" - - @trace_method - async def execute( - self, - function_name: str, - function_args: dict, - tool: Tool, - actor: User, - agent_state: Optional[AgentState] = None, - sandbox_config: Optional[SandboxConfig] = None, - sandbox_env_vars: Optional[Dict[str, Any]] = None, - ) -> ToolExecutionResult: - function_map = {"run_code": self.run_code, "web_search": self.web_search} - - if function_name not in function_map: - raise ValueError(f"Unknown function: {function_name}") - - # Execute the appropriate function - function_args_copy = function_args.copy() # Make a copy to avoid modifying the original - function_response = await function_map[function_name](**function_args_copy) - - return ToolExecutionResult( - status="success", - func_return=function_response, - agent_state=agent_state, - ) - - async def run_code(self, code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str: - from e2b_code_interpreter import AsyncSandbox - - if tool_settings.e2b_api_key is None: - raise ValueError("E2B_API_KEY is not set") - - sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key) - params = {"code": code} - if language != "python": - # Leave empty for python - params["language"] = language - - res = self._llm_friendly_result(await sbx.run_code(**params)) - return json.dumps(res, ensure_ascii=False) - - def _llm_friendly_result(self, res): - out = { - "results": [r.text if hasattr(r, "text") else str(r) for r in res.results], - "logs": { - "stdout": getattr(res.logs, "stdout", []), - "stderr": getattr(res.logs, "stderr", []), - }, - } - err = getattr(res, "error", None) - if err is not None: - out["error"] = err - return out - - async def web_search(agent_state: "AgentState", query: str) -> str: - """ - Search the web for information. - Args: - query (str): The query to search the web for. - Returns: - str: The search results. - """ - - try: - from tavily import AsyncTavilyClient - except ImportError: - raise ImportError("tavily is not installed in the tool execution environment") - - # Check if the API key exists - if tool_settings.tavily_api_key is None: - raise ValueError("TAVILY_API_KEY is not set") - - # Instantiate client and search - tavily_client = AsyncTavilyClient(api_key=tool_settings.tavily_api_key) - search_results = await tavily_client.search(query=query, auto_parameters=True) - - results = search_results.get("results", []) - if not results: - return "No search results found." - - # ---- format for the LLM ------------------------------------------------- - formatted_blocks = [] - for idx, item in enumerate(results, start=1): - title = item.get("title") or "Untitled" - url = item.get("url") or "Unknown URL" - # keep each content snippet reasonably short so you don’t blow up context - content = ( - shorten(item.get("content", "").strip(), width=600, placeholder=" …") - if WEB_SEARCH_CLIP_CONTENT - else item.get("content", "").strip() - ) - score = item.get("score") - if WEB_SEARCH_INCLUDE_SCORE: - block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n" - else: - block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n" - formatted_blocks.append(block) - - return WEB_SEARCH_SEPARATOR.join(formatted_blocks) diff --git a/letta/services/tool_executor/tool_executor_base.py b/letta/services/tool_executor/tool_executor_base.py new file mode 100644 index 00000000..a8a7ccb2 --- /dev/null +++ b/letta/services/tool_executor/tool_executor_base.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + +from letta.schemas.agent import AgentState +from letta.schemas.sandbox_config import SandboxConfig +from letta.schemas.tool import Tool +from letta.schemas.tool_execution_result import ToolExecutionResult +from letta.schemas.user import User +from letta.services.agent_manager import AgentManager +from letta.services.block_manager import BlockManager +from letta.services.message_manager import MessageManager +from letta.services.passage_manager import PassageManager + + +class ToolExecutor(ABC): + """Abstract base class for tool executors.""" + + def __init__( + self, + message_manager: MessageManager, + agent_manager: AgentManager, + block_manager: BlockManager, + passage_manager: PassageManager, + actor: User, + ): + self.message_manager = message_manager + self.agent_manager = agent_manager + self.block_manager = block_manager + self.passage_manager = passage_manager + self.actor = actor + + @abstractmethod + async def execute( + self, + function_name: str, + function_args: dict, + tool: Tool, + actor: User, + agent_state: Optional[AgentState] = None, + sandbox_config: Optional[SandboxConfig] = None, + sandbox_env_vars: Optional[Dict[str, Any]] = None, + ) -> ToolExecutionResult: + """Execute the tool and return the result.""" diff --git a/letta/services/tool_manager.py b/letta/services/tool_manager.py index 78652d4d..78daed8a 100644 --- a/letta/services/tool_manager.py +++ b/letta/services/tool_manager.py @@ -1,7 +1,7 @@ import asyncio import importlib import warnings -from typing import List, Optional, Union +from typing import List, Optional, Set, Union from letta.constants import ( BASE_FUNCTION_RETURN_CHAR_LIMIT, @@ -11,6 +11,8 @@ from letta.constants import ( BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, BUILTIN_TOOLS, + FILES_TOOLS, + LETTA_TOOL_MODULE_NAMES, LETTA_TOOL_SET, MCP_TOOL_TAG_NAME_PREFIX, MULTI_AGENT_TOOLS, @@ -22,12 +24,12 @@ from letta.orm.enums import ToolType # TODO: Remove this once we translate all of these to the ORM from letta.orm.errors import NoResultFound from letta.orm.tool import Tool as ToolModel +from letta.otel.tracing import trace_method from letta.schemas.tool import Tool as PydanticTool from letta.schemas.tool import ToolCreate, ToolUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry from letta.services.mcp.types import SSEServerConfig, StdioServerConfig -from letta.tracing import trace_method from letta.utils import enforce_types, printd logger = get_logger(__name__) @@ -368,12 +370,10 @@ class ToolManager: def upsert_base_tools(self, actor: PydanticUser) -> List[PydanticTool]: """Add default tools in base.py and multi_agent.py""" functions_to_schema = {} - module_names = ["base", "multi_agent", "voice", "builtin"] - for module_name in module_names: - full_module_name = f"letta.functions.function_sets.{module_name}" + for module_name in LETTA_TOOL_MODULE_NAMES: try: - module = importlib.import_module(full_module_name) + module = importlib.import_module(module_name) except Exception as e: # Handle other general exceptions raise e @@ -407,6 +407,9 @@ class ToolManager: elif name in BUILTIN_TOOLS: tool_type = ToolType.LETTA_BUILTIN tags = [tool_type.value] + elif name in FILES_TOOLS: + tool_type = ToolType.LETTA_FILES_CORE + tags = [tool_type.value] else: raise ValueError( f"Tool name {name} is not in the list of base tool names: {BASE_TOOLS + BASE_MEMORY_TOOLS + MULTI_AGENT_TOOLS + BASE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS}" @@ -431,66 +434,59 @@ class ToolManager: @enforce_types @trace_method - async def upsert_base_tools_async(self, actor: PydanticUser) -> List[PydanticTool]: - """Add default tools in base.py and multi_agent.py""" + async def upsert_base_tools_async( + self, + actor: PydanticUser, + allowed_types: Optional[Set[ToolType]] = None, + ) -> List[PydanticTool]: + """Add default tools defined in the various function_sets modules, optionally filtered by ToolType.""" + functions_to_schema = {} - module_names = ["base", "multi_agent", "voice", "builtin"] - - for module_name in module_names: - full_module_name = f"letta.functions.function_sets.{module_name}" + for module_name in LETTA_TOOL_MODULE_NAMES: try: - module = importlib.import_module(full_module_name) - except Exception as e: - # Handle other general exceptions - raise e - - try: - # Load the function set + module = importlib.import_module(module_name) functions_to_schema.update(load_function_set(module)) except ValueError as e: - err = f"Error loading function set '{module_name}': {e}" - warnings.warn(err) + warnings.warn(f"Error loading function set '{module_name}': {e}") + except Exception as e: + raise e - # create tool in db tools = [] for name, schema in functions_to_schema.items(): - if name in LETTA_TOOL_SET: - if name in BASE_TOOLS: - tool_type = ToolType.LETTA_CORE - tags = [tool_type.value] - elif name in BASE_MEMORY_TOOLS: - tool_type = ToolType.LETTA_MEMORY_CORE - tags = [tool_type.value] - elif name in MULTI_AGENT_TOOLS: - tool_type = ToolType.LETTA_MULTI_AGENT_CORE - tags = [tool_type.value] - elif name in BASE_SLEEPTIME_TOOLS: - tool_type = ToolType.LETTA_SLEEPTIME_CORE - tags = [tool_type.value] - elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS: - tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE - tags = [tool_type.value] - elif name in BUILTIN_TOOLS: - tool_type = ToolType.LETTA_BUILTIN - tags = [tool_type.value] - else: - raise ValueError( - f"Tool name {name} is not in the list of base tool names: {BASE_TOOLS + BASE_MEMORY_TOOLS + MULTI_AGENT_TOOLS + BASE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS}" - ) + if name not in LETTA_TOOL_SET: + continue - # create to tool - tools.append( - self.create_or_update_tool_async( - PydanticTool( - name=name, - tags=tags, - source_type="python", - tool_type=tool_type, - return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT, - ), - actor=actor, - ) + if name in BASE_TOOLS: + tool_type = ToolType.LETTA_CORE + elif name in BASE_MEMORY_TOOLS: + tool_type = ToolType.LETTA_MEMORY_CORE + elif name in BASE_SLEEPTIME_TOOLS: + tool_type = ToolType.LETTA_SLEEPTIME_CORE + elif name in MULTI_AGENT_TOOLS: + tool_type = ToolType.LETTA_MULTI_AGENT_CORE + elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS: + tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE + elif name in BUILTIN_TOOLS: + tool_type = ToolType.LETTA_BUILTIN + elif name in FILES_TOOLS: + tool_type = ToolType.LETTA_FILES_CORE + else: + raise ValueError(f"Tool name {name} is not recognized in any known base tool set.") + + if allowed_types is not None and tool_type not in allowed_types: + continue + + tools.append( + self.create_or_update_tool_async( + PydanticTool( + name=name, + tags=[tool_type.value], + source_type="python", + tool_type=tool_type, + return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT, + ), + actor=actor, ) + ) - # TODO: Delete any base tools that are stale return await asyncio.gather(*tools) diff --git a/letta/services/tool_sandbox/e2b_sandbox.py b/letta/services/tool_sandbox/e2b_sandbox.py index ea07f1f1..fb44fc94 100644 --- a/letta/services/tool_sandbox/e2b_sandbox.py +++ b/letta/services/tool_sandbox/e2b_sandbox.py @@ -3,13 +3,13 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from e2b_code_interpreter import AsyncSandbox from letta.log import get_logger +from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState from letta.schemas.sandbox_config import SandboxConfig, SandboxType from letta.schemas.tool import Tool from letta.schemas.tool_execution_result import ToolExecutionResult from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort from letta.services.tool_sandbox.base import AsyncToolSandboxBase -from letta.tracing import log_event, trace_method from letta.types import JsonDict from letta.utils import get_friendly_error_msg diff --git a/letta/services/tool_sandbox/local_sandbox.py b/letta/services/tool_sandbox/local_sandbox.py index 8fe34870..3d716137 100644 --- a/letta/services/tool_sandbox/local_sandbox.py +++ b/letta/services/tool_sandbox/local_sandbox.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional from pydantic.config import JsonDict +from letta.otel.tracing import log_event, trace_method from letta.schemas.agent import AgentState from letta.schemas.sandbox_config import SandboxConfig, SandboxType from letta.schemas.tool import Tool @@ -20,7 +21,6 @@ from letta.services.helpers.tool_execution_helper import ( from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort from letta.services.tool_sandbox.base import AsyncToolSandboxBase from letta.settings import tool_settings -from letta.tracing import log_event, trace_method from letta.utils import get_friendly_error_msg, parse_stderr_error_msg @@ -152,8 +152,11 @@ class AsyncToolSandboxLocal(AsyncToolSandboxBase): print(f"Auto-generated code for debugging:\n\n{code}") raise e finally: - # Clean up the temp file - os.remove(temp_file_path) + # Clean up the temp file if not debugging + from letta.settings import settings + + if not settings.debug: + os.remove(temp_file_path) async def _prepare_venv(self, local_configs, venv_path: str, env: Dict[str, str]): """ diff --git a/letta/services/user_manager.py b/letta/services/user_manager.py index 68970412..a4ad5ac9 100644 --- a/letta/services/user_manager.py +++ b/letta/services/user_manager.py @@ -5,11 +5,11 @@ from sqlalchemy import select, text from letta.orm.errors import NoResultFound from letta.orm.organization import Organization as OrganizationModel from letta.orm.user import User as UserModel +from letta.otel.tracing import trace_method from letta.schemas.user import User as PydanticUser from letta.schemas.user import UserUpdate from letta.server.db import db_registry from letta.services.organization_manager import OrganizationManager -from letta.tracing import trace_method from letta.utils import enforce_types from letta.settings import settings diff --git a/letta/settings.py b/letta/settings.py index 0982c827..6d7e05b2 100644 --- a/letta/settings.py +++ b/letta/settings.py @@ -192,13 +192,17 @@ class Settings(BaseSettings): pool_use_lifo: bool = True disable_sqlalchemy_pooling: bool = False + redis_host: Optional[str] = None + redis_port: Optional[int] = None + + plugin_register: Optional[str] = None + # multi agent settings multi_agent_send_message_max_retries: int = 3 multi_agent_send_message_timeout: int = 20 * 60 multi_agent_concurrent_sends: int = 50 # telemetry logging - verbose_telemetry_logging: bool = False otel_exporter_otlp_endpoint: Optional[str] = None # otel default: "http://localhost:4317" disable_tracing: bool = False llm_api_logging: bool = True @@ -210,6 +214,7 @@ class Settings(BaseSettings): use_uvloop: bool = False use_granian: bool = False + sqlalchemy_tracing: bool = False # event loop parallelism event_loop_threadpool_max_workers: int = 43 @@ -258,6 +263,15 @@ class Settings(BaseSettings): else: return None + @property + def plugin_register_dict(self) -> dict: + plugins = {} + if self.plugin_register: + for plugin in self.plugin_register.split(";"): + name, target = plugin.split("=") + plugins[name] = {"target": target} + return plugins + class TestSettings(Settings): model_config = SettingsConfigDict(env_prefix="letta_test_", extra="ignore") @@ -265,9 +279,15 @@ class TestSettings(Settings): letta_dir: Optional[Path] = Field(Path.home() / ".letta/test", env="LETTA_TEST_DIR") +class LogSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="letta_logging_", extra="ignore") + verbose_telemetry_logging: bool = False + + # singleton settings = Settings(_env_parse_none_str="None") test_settings = TestSettings() model_settings = ModelSettings() tool_settings = ToolSettings() summarizer_settings = SummarizerSettings() +log_settings = LogSettings() diff --git a/letta/utils.py b/letta/utils.py index 1321bc36..c3183f92 100644 --- a/letta/utils.py +++ b/letta/utils.py @@ -515,6 +515,11 @@ def is_optional_type(hint): def enforce_types(func): + """Enforces that values passed in match the expected types. + + Technically will handle coroutines as well. + """ + @wraps(func) def wrapper(*args, **kwargs): # Get type hints, excluding the return type hint @@ -1078,9 +1083,9 @@ def log_telemetry(logger: Logger, event: str, **kwargs): :param event: A string describing the event. :param kwargs: Additional key-value pairs for logging metadata. """ - from letta.settings import settings + from letta.settings import log_settings - if settings.verbose_telemetry_logging: + if log_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/otel/otel-collector-config-clickhouse-dev.yaml b/otel/otel-collector-config-clickhouse-dev.yaml index 1c9c0cca..f4a7c374 100644 --- a/otel/otel-collector-config-clickhouse-dev.yaml +++ b/otel/otel-collector-config-clickhouse-dev.yaml @@ -12,12 +12,18 @@ processors: send_batch_size: 1024 exporters: - file: + file/traces: path: ${HOME}/.letta/logs/traces.json rotation: max_megabytes: 100 max_days: 7 max_backups: 5 + file/metrics: + path: ${HOME}/.letta/logs/metrics.json + rotation: + max_megabytes: 100 + max_days: 7 + max_backups: 5 clickhouse: endpoint: ${CLICKHOUSE_ENDPOINT} database: ${CLICKHOUSE_DATABASE} @@ -40,4 +46,8 @@ service: traces: receivers: [otlp] processors: [batch] - exporters: [file, clickhouse] + exporters: [file/traces, clickhouse] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [file/metrics, clickhouse] diff --git a/poetry.lock b/poetry.lock index b52e54b5..b0d93fdb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,98 +14,98 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.7" +version = "3.12.9" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiohttp-3.12.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4486f399573c94b223411bc5686b5cdc661f4dd67daece800662356e46b3a2b5"}, - {file = "aiohttp-3.12.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67759acb11673c1b976a516f2d69a73433aad70ed04e44ce79eaf0e58219535e"}, - {file = "aiohttp-3.12.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8de89889df856101176ccaf570075b73b62ea9d86e11e642d0f20ecd62a34ce8"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53ae6140303ab04a7203f8fcb9ca5b2c5abea46e12e8d6f65575d0f197812e22"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0d6575df942e7991e1450b731ad9a5726a1116668471a07d749bd9b2cb1f30a7"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:56d0f622b3595f3aeaefd07aca9d425748fc8bf5e9e502f75a2dad15f2b875b2"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c7c848ad08722bfc9da0b9fe5f44cde4fa6499d34ece11462c5b7b1f75a5a1b"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b56a4fb31fe82ee58cd8cc157e4fc58d19fba2580b46a62fe7808353bb9b82df"}, - {file = "aiohttp-3.12.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbdb60ab46f696a5e52d98a830b11c034d601bbe2496a82a19d94268257ac63b"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8eb5d60790ca3563a376ef297dfac3c4c5ec7a7e180b9fe0314f238813fd2ab0"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f1a478d055c77fa549251d8b2a8a850918edbbf9941245ef6edbbb65d924edd7"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c4e7155fbdf89084abde1b33f05d681d8ffa0d5d07698d5d76a03ebdeb062848"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ed109a3eef13620c8ce57c429119990be08782c346465c265a23052e41e2cf42"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:777663011746b37b5df13df7826cb28ebc447b21ac8aa8278b7825404897dd5f"}, - {file = "aiohttp-3.12.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66605ac59c9fbcd4159b0c0cfa239173ab77abc18cf714a1d0569cbabe3c836d"}, - {file = "aiohttp-3.12.7-cp310-cp310-win32.whl", hash = "sha256:d909d0b217e85f366bfff45298966ea0dc49d76666fef2eb5777adc5b7aaa292"}, - {file = "aiohttp-3.12.7-cp310-cp310-win_amd64.whl", hash = "sha256:362832e0b7c46c7ad3cf2f693061e17f1198f8d7fa4e907c304b3208241161c8"}, - {file = "aiohttp-3.12.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:388b5947aa6931ef4ce3ed4edde6853e84980677886992cfadcf733dd06eed63"}, - {file = "aiohttp-3.12.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ed5af1cce257cca27a3e920b003b3b397f63418a203064b7d804ea3b45782af"}, - {file = "aiohttp-3.12.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f466ae8f9c02993b7d167be685bdbeb527cf254a3cfcc757697e0e336399d0a2"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2be095a420a9f9a12eff343d877ae180dd919238b539431af08cef929e874759"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b058cf2ba6adba699960d7bc403411c8a99ab5d3e5ea3eb01473638ae7d1a30e"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b6a660163b055686dbb0acc961978fd14537eba5d9da6cbdb4dced7a8d3be1a"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d741923905f267ad5d5c8f86a56f9d2beac9f32a36c217c5d9ef65cd74fd8ca0"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:519f5454b6018158ae0e789b8f6a88726c47dd680982eb318ef3ca4dee727314"}, - {file = "aiohttp-3.12.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e4eebfe470e22cc4b374d7e32c07e96d777a5c0fa51f3824de68e697da037ec"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74ff39445f94923cf595e9e6dd602ecbe66b12364e2207e61342b8834417f8da"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:77cb9dba16486ecfeac8076763600b9714941e0ff696e53a30e8d408d9a196ca"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a7b3b9cbe83e3918a1918b0de274884f17b64224c1c9210a6fb0f7c10d246636"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6055f53c70938498884e71ca966abe8e9e7558489e13a7e40b6384dee7230d1d"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8493a42d5b2a736c6804239b985feebeea1c60f8fcb46a3607d6dce3c1a42b12"}, - {file = "aiohttp-3.12.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:372f2237cade45f563d973c2a913895f2699a892c0eb11c55c6880b6f0acf219"}, - {file = "aiohttp-3.12.7-cp311-cp311-win32.whl", hash = "sha256:41f686749a099b507563a5c0cb4fd77367b05448a2c1758784ad506a28e9e579"}, - {file = "aiohttp-3.12.7-cp311-cp311-win_amd64.whl", hash = "sha256:7a3691583470d4397aca70fbf8e0f0778b63a2c2a6a23263bdeeb68395972f29"}, - {file = "aiohttp-3.12.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9b9345918f5b5156a5712c37d1d331baf320df67547ea032a49a609b773c3606"}, - {file = "aiohttp-3.12.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3091b4883f405dbabeb9ea821a25dec16d03a51c3e0d2752fc3ab48b652bf196"}, - {file = "aiohttp-3.12.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:97fd97abd4cf199eff4041d0346a7dc68b60deab177f01de87283be513ffc3ab"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a5938973105cd5ff17176e8cb36bc19cac7c82ae7c58c0dbd7e023972d0c708"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e506ae5c4c05d1a1e87edd64b994cea2d49385d41d32e1c6be8764f31cf2245c"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b780b402e6361c4cfcec252580f5ecdd86cb68376520ac34748d3f8b262dd598"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf981bbfb7ff2ebc1b3bfae49d2efe2c51ca1cf3d90867f47c310df65398e85e"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f98e0e5a49f89b252e115844f756c04fc8050f38252a32a3dd994ce8121f10"}, - {file = "aiohttp-3.12.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:410e96cc6824fc4ced9703fb2ac2d06c6190d21fc6f5b588f62b1918628449c1"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:43e93987fe9df4349db8deae7c391695538c35e4ba893133c7e823234f6e4537"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb3f3dcb59f3e16819a1c7d3fa32e7b87255b661c1e139a1b5940bde270704ab"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4a46fe4a4c66b2712059e48a8384eb93565fbe3251af4844860fed846ef4ca75"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ad01793164661af70918490ef8efc2c09df7a3c686b6c84ca90a2d69cdbc3911"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e85c6833be3f49cead2e7bc79080e5c18d6dab9af32226ab5a01dc20c523e7d9"}, - {file = "aiohttp-3.12.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c9f52149d8249566e72c50c7985c2345521b3b78f84aa86f6f492cd50b14793"}, - {file = "aiohttp-3.12.7-cp312-cp312-win32.whl", hash = "sha256:0e1c33ac0f6a396bcefe9c1d52c9d38a051861885a5c102ca5c8298aba0108fa"}, - {file = "aiohttp-3.12.7-cp312-cp312-win_amd64.whl", hash = "sha256:b4aed5233a9d13e34e8624ecb798533aa2da97e7048cc69671b7a6d7a2efe7e8"}, - {file = "aiohttp-3.12.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:adbb2046600a60e37a54ea9b77b0ddef280029b0a853624a8e9b2b71a037c890"}, - {file = "aiohttp-3.12.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76392cbadc1ccc0a8c02098b74c0240d53c644b10a81e1addbc1666dce3cd62a"}, - {file = "aiohttp-3.12.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6f04af3bf040dc8fd9b2bc0e465f5aca6fc5349fa08bd7f08142974a2ded21bf"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b19763f88f058e9c605f79cde8a800660f7e259162b80982111cc631dfc54bf0"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6600550593c440ef29ca2a14b8a52ac91b9f494d85f75294409ec6ad5637476f"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b83c829be3cddaf958dee8108e09b1502c215e95064d3045015298dbded54a"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffa9928fd37061c8e35b85d3f1b4a256d0c3e8cbd421c1d8bd0ab45461b6a838"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc8086515dd1016b67db9ccebb7159234226dba99fb6a895a0c9270b644cf525"}, - {file = "aiohttp-3.12.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c19b1de25703560fa64f998dfc3685040b52996056e048b3406c8e97dcfa1e3"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6357abdc7a2cfb113274c4f4a7f086bdca36905924953bf7a9e3f6add3aec7c5"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:29ff7876ff7e4a8029642334a81891cb5a842f1e405195c2946f697102756670"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5e7741c53d473204f89dd26f3b087a5883c742add8d6504d0d7d3ad3ff1cd1b7"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:38dc536059cc0624e22273905a1df74b231ac903d73af59ee6e6e3139f05a28b"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:82a59cf086396a409d6d2350c122aada07f1f56bb529734994d37bcafc8cf101"}, - {file = "aiohttp-3.12.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cd6e299292ba085a3642cb4085b393f45bbca45c067182d15e33c2e3473283c"}, - {file = "aiohttp-3.12.7-cp313-cp313-win32.whl", hash = "sha256:4acec2b5de65adc469837260be8408d5f53d4c8ae60631be868e9d7eb8563167"}, - {file = "aiohttp-3.12.7-cp313-cp313-win_amd64.whl", hash = "sha256:93317649d65cc895ba1fe5384353cb6c44638db39ebb55dabe3dade34a1b1177"}, - {file = "aiohttp-3.12.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f85e48970aff5b00af94a5f6311ee0b61eca8bbc8769df39873fc68d747ca609"}, - {file = "aiohttp-3.12.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bca9329faa73c42061a67b8b53e6b1d46b73e3411636bfe1d07c58d81067b902"}, - {file = "aiohttp-3.12.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e80ef94a0993c7124b69bf1a95b5d26f22f24e5fdc6af22ca143105edde22ed"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e1f6e7825d3830ee85ddf5d322300d15053e94c66ff8b3d5e8ef0f152c0f1a"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:845a67d26ee9578d20738975591dccd0fcae7104c89cc112316787f9fdfe8b61"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1a280e27b2c772a9d69dfd0744929f8628a6b8b6e6e87c0125c8c417501a21"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:838a091be15ce619a83896c8485e814215f3383952dd58aec932d0f9ae85a02b"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ea0db720f2996f9b799c8ba6fbdd12063add509a81a398cd31a3fb152efae0d"}, - {file = "aiohttp-3.12.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ccd1e07b61c26532f1a7908430c30d687425bbf2d4da26f09bc1f2acf06a5f9"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b1f532d312a42397e6f591499acf707cece6462f745c5670bb7c70d1bb963882"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:f8fa7c8ee01b54367cafb7e82947e36e57f9cb243d7c4d66e03fb96661b082ae"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:81a1ca045593149d3366286c30c57ebb63d2f28feca8ca3fae049c22ed8520c4"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9ca179427f7cbd3476eca3bfc229087c112b0418242c5b56f9f0f9c0e681b906"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4ee037aec7ccc8777b0f9603085a2c53108368443624f7dc834028b16591541"}, - {file = "aiohttp-3.12.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2804245093897b77736540f75826d35587819e143f0f95e951bfea8eb312bf09"}, - {file = "aiohttp-3.12.7-cp39-cp39-win32.whl", hash = "sha256:c8d9b576aa4e1359fcc479532b8a21803840fd61013eec875746b29c3930f073"}, - {file = "aiohttp-3.12.7-cp39-cp39-win_amd64.whl", hash = "sha256:1496c9e785d0432a4eae6c059f1d68423fb6264cbdacaff2d9ab1859be66c5bb"}, - {file = "aiohttp-3.12.7.tar.gz", hash = "sha256:08bf55b216c779eddb6e41c1841c17d7ddd12776c7d7b36051c0a292a9ca828e"}, + {file = "aiohttp-3.12.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:abb01935bb606bbc080424799bfda358d38374c45a7cbbc89f9bb330deb1db26"}, + {file = "aiohttp-3.12.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e2337516411cd15b7257736484dfd5101fa0e6b11ef2086b4bb6db9365373dcb"}, + {file = "aiohttp-3.12.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:26874b2c61ab5d1e05d942d7254a565eeec11750bf8f1a8995c33d6d772f5015"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43dbedb626c6bb03cc8e9ab27b9da4414bc5540d3fe1bce0e687e50c20553689"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:18897f24e80bac4e7df5d37375ab22391f8b7beedfe617f8de064dbfd76ca36b"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2466804eaa42bf6340de28fba7254709db788989b891a7c5bd57a84f5a11c04b"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85ddf89da86915ab327fafe9059540707b9deac7cfad1dfda4621eac6590aa16"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8d89c0ea455b8e8e386db8b82a55671703d4868c7c1e38cca0d643232f50f8d"}, + {file = "aiohttp-3.12.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ee5ca28436b9203d020924c6dacc1cca4e77acf5f8f5c5d236b123c0158a012"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ca2ad779958e1beb2f139e7d45f84c13f94f6c0f63025e435e31f3247cb5a05"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:daae5ea9c06daacb056351273a38d4465446fbb5c8c8107a6f93db3e1d5bc4e8"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:52cec94fa76e488b0ebc6586507421116d7993c7984ea020529107796b206117"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:db2aef30d877f44716c8ce4adb2162c7ccb9c58d6153bc68bd2cfb3fbd7d6a95"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1d205549f965bc69c377206643b06fd78d77ed20b8735765c54153cf00a51465"}, + {file = "aiohttp-3.12.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3fdaaf63a778ae020b9bf8a7ae4a80f87deb88152aad259764e994b3efe44d38"}, + {file = "aiohttp-3.12.9-cp310-cp310-win32.whl", hash = "sha256:7aecd5546e5c65e4904fc697806a4830c2a4870cb7bae28a7f483db008bba3dc"}, + {file = "aiohttp-3.12.9-cp310-cp310-win_amd64.whl", hash = "sha256:5cf338d75be82709bf1c8d8404f347661819c1cc9f34798d5b762377fd70ccd6"}, + {file = "aiohttp-3.12.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:301eebd8e1134a8457151b451841a47d3440ce79fa9a0d1c70650bda624cbd69"}, + {file = "aiohttp-3.12.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d8ba7652d815bd5b99189d5b685db5509a08f1282e047a849b7f4353df8a95c"}, + {file = "aiohttp-3.12.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:998a6e143b2a4ffee14fb2c2ff5a3338d70d811be3f5d4a13a305ee0f4c6ac42"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d011b13f3bfcf711ce9007ea08305a582135ee2105dc3202b011c055c1ac6f1"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3c7b314d565e235051893a46e14ea14ab05bb17fe99bdb2cf85e9adc62b4836c"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2bb6408bc2cb8ee5be4efb18bcfcfce4d76448f62237074917e146a425daf425"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9ad4fe8d068544ba5d77500ea2d450f130109a4b0caf6d9197167303250f683"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55721245164191ac92808ad39f3b2876195b1e6521ead0aad7f1c9ae69568b1a"}, + {file = "aiohttp-3.12.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5c5fbc9217578f5c9b5a65f27dfb044283b437cfa9cf52531f3ce94dca1e912"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5c7e03f6dd8210b76587cb17088b3e5e0dabfc6787d42db58bc933da932230b7"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c892b2400c0795bbf00303282029c66e8ba912dc9fabf4728ba69a63046c8020"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4de97019fec6f236671ee5d5831cebf67fbd52ee6bd47e2b8c9941cd39698db1"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:941cd1ce3d1f605fd062857b339f7c3cde5ce83392bfb1029c3de782b8f98b52"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:43f3d4d6264629d97d44a6d75603923c2c63dad6aff2f72b172635c43db739db"}, + {file = "aiohttp-3.12.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bbe5ab33a6810e9839270b3673eba683b9f91ed011be66feb4823f9fecf1bb73"}, + {file = "aiohttp-3.12.9-cp311-cp311-win32.whl", hash = "sha256:9ec207177e0adc694ed4a41ca8ebdb4008edb8d475a8b94d71d73414fc4707b6"}, + {file = "aiohttp-3.12.9-cp311-cp311-win_amd64.whl", hash = "sha256:965d93b08eed59359721a324b998ebf5354c9049b17cd93d9de50c14092b6ace"}, + {file = "aiohttp-3.12.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7ae744b61b395e04b3d1acbbd301d98249397333f49419039517226ff32f3aa7"}, + {file = "aiohttp-3.12.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d467a2049c4405853799dea41474b0ea9852fd465e7e2df819d3a33ac53214e8"}, + {file = "aiohttp-3.12.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba7a8b5f02c2826eb29e8d6c38f1bc509efb506a2862131079b5b8d880ed4b62"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfe590ddb0dca3cdb601787079276545f00cfb9493f73f00fa011e71dae6f5fd"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fc441aba05efec5c72127393f56206d0f3fb113aadcd1685033c10da1ff582ad"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a3f20a1b72643a0be5c9fcb97eb22607fcca32f1ca497f09a88d1ec3109daae"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3647dd1da43d595a52c5071b68fd8d39c0fd25b80f2cdd83eaabd9d59cd1f139"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:970bae350cedbabb7c9d0fc8564b004a547d4a27cf12dc986be0abf7d8cc8d81"}, + {file = "aiohttp-3.12.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ccc5a5a4ccfa0ef0191dad2926e9752c37f368d846a70e40095a8529c5fb6eb"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:55197e86994682a332e8943eb01b462ae25630b10f245812e517251d7a922f25"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:94d0cf6606ed9f2373565b8d0005bb070afbb81525ef6fa6e0725b8aec0c0843"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0575d7ae9a9c206276a6aaa3ce364b467f29f0497c0db4449de060dc341d88d6"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9f44a4ebd717cc39796c4647495bc2901d0c168c71cd0132691ae3d0312215a9"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f9cdadfe84beb8ceafa98ab676e8c0caf1e5d60e8b33c385c11259ee0f7f2587"}, + {file = "aiohttp-3.12.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:995b5640969b1250e37be6fc92d185e523e8df446f8bfa723b347e52d7ae80f9"}, + {file = "aiohttp-3.12.9-cp312-cp312-win32.whl", hash = "sha256:4cfa37e0797510fdb20ab0ee3ad483ae7cfacb27c6fb8de872a998705ad2286a"}, + {file = "aiohttp-3.12.9-cp312-cp312-win_amd64.whl", hash = "sha256:fdbd04e9b05885eaaefdb81c163b6dc1431eb13ee2da16d82ee980d4dd123890"}, + {file = "aiohttp-3.12.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bf6fac88666d7e4c6cfe649d133fcedbc68e37a4472e8662d98a7cf576207303"}, + {file = "aiohttp-3.12.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:74e87ea6c832311b18a32b06baa6fee90a83dd630de951cca1aa175c3c9fa1ce"}, + {file = "aiohttp-3.12.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16627b4caf6a36b605e3e1c4847e6d14af8e8d6b7dad322935be43237d4eb10d"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:998e323c107c3f6396c1f9de72289009057c611942771f24114ae78a76af0af5"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:20f8a6d3af13f043a09726add6d096b533f180cf8b43970a8d9c9ca978bf45c5"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0bd0e06c8626361027f69df510c8484e17568ba2f91b2de51ea055f86ed3b071"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64e22f12dd940a6e7b923637b10b611b752f6117bc3a780b7e61cc43c9e04892"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11b5bf453056b6ac4924ede1188d01e8b8d4801a6aa5351da3a7dbdbc03cb44e"}, + {file = "aiohttp-3.12.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00369db59f09860e0e26c75035f80f92881103e90f5858c18f29eb4f8cb8970f"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:80fa1efc71d423be25db9dddefe8dcd90e487fbc9351a59549521b66405e71de"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5cade22a0f0a4665003ded2bc4d43bb69fde790e5a287187569509c33333a3ab"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d4a0fe3cd45cf6fb18222deef92af1c3efe090b7f43d477de61b2360c90a4b32"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:97b036ce251825fd5ab69d302ca8a99d3352af1c616cf40b2306fdb734cd6d30"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:eeac3a965552dbf79bcc0b9b963b5f7d6364b1542eb609937278d70d27ae997f"}, + {file = "aiohttp-3.12.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a1f72b2560beaa949b5d3b324fc07b66846d39a8e7cc106ca450312a5771e3e"}, + {file = "aiohttp-3.12.9-cp313-cp313-win32.whl", hash = "sha256:e429fce99ac3fd6423622713d2474a5911f24816ccdaf9a74c3ece854b7375c1"}, + {file = "aiohttp-3.12.9-cp313-cp313-win_amd64.whl", hash = "sha256:ccb1931cc8b4dc6d7a2d83db39db18c3f9ac3d46a59289cea301acbad57f3d12"}, + {file = "aiohttp-3.12.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:aee2910e6f06f6d229c3b90e277685a8f25fde54b3a4220cdf5901c925d681c3"}, + {file = "aiohttp-3.12.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d06286278ff413a1a410b6d4f7712e734dbceb2e352fab89b9c4448dd9f3d679"}, + {file = "aiohttp-3.12.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8f48df4f6061d4eb0c43867f8b82575bcfe05c8780ff9f21e811535458f6e0c"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:495b2ac780e4d4f9a67fc79b7e84f21b09661f362b93d43360204a7bfecc4fec"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6612437f2c761dd0b31569b28b8905bccfb88dc1aeecc9ad20fbaf346eafe989"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4351fb8d4b12b15f39ed076a21d53f9542bc0db09ba973c04503b31ef8268332"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4027f160e5109d6aac1537426d8b6e693fcca393dd9488d986ec855caf6dc4f6"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30a55cdc682d98b8f7f1e8d3505846ab302a5547ffb7cef85607448b090d691d"}, + {file = "aiohttp-3.12.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f91ee8ed3d9ccb832dbc93e6b9d85c2a9dc73a7ea5d0f3ee4c3b64136f6ba598"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:325acbe0c0225836e720eb758672c2f39e3017e89389de1dfd7fba7977b9bb82"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:075da814b9a639904041d8d50e3ed665ea892df4e99278f8b63ff0ee549eb519"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:57971e7adbe0984d9736836d7a34bd615119e628f04dfca302c1bf0ec3d39a77"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0954f990f274cfcbbd08d8fdb4a0c7949ac753bc1ea344c540829a85b0a8f34d"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:daaf5a5f2340f46291ab7d44f60693cc71a05a8b9104e6efd3bd51c8a6526290"}, + {file = "aiohttp-3.12.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ba0843970e8a9cb4ddae47281010997f5b1a1c8cbc635fbefc9a0ccaa7c95606"}, + {file = "aiohttp-3.12.9-cp39-cp39-win32.whl", hash = "sha256:b06acaba86c46335a862ca0805cd695610bcb785d1a18f9f6498711178974e4b"}, + {file = "aiohttp-3.12.9-cp39-cp39-win_amd64.whl", hash = "sha256:0c4f87ee9451ce5e453af2cd868f4a42ea2f49c5aff6e8114cded0f47ed9ea9b"}, + {file = "aiohttp-3.12.9.tar.gz", hash = "sha256:2c9914c8914ff40b68c6e4ed5da33e88d4e8f368fddd03ceb0eb3175905ca782"}, ] [package.dependencies] @@ -366,6 +366,19 @@ files = [ {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "(extra == \"redis\" or extra == \"all\") and python_full_version < \"3.11.3\" and python_version == \"3.11\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + [[package]] name = "asyncpg" version = "0.30.0" @@ -658,19 +671,19 @@ files = [ [[package]] name = "boto3" -version = "1.38.29" +version = "1.38.32" description = "The AWS SDK for Python" optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"bedrock\"" files = [ - {file = "boto3-1.38.29-py3-none-any.whl", hash = "sha256:90a9b1a08122b840216b0e33b7b0dbe4ef50f12d00a573bf7b030cddeda9c507"}, - {file = "boto3-1.38.29.tar.gz", hash = "sha256:0777a87e8d28ebae09a086017a53bcaf25ec7c094d8f7e4122b265aa48e273f5"}, + {file = "boto3-1.38.32-py3-none-any.whl", hash = "sha256:b998edac72f6740bd5d9d585cf3880f2dfeb4842e626b34430fd0e9623378011"}, + {file = "boto3-1.38.32.tar.gz", hash = "sha256:3faa2c328a61745f3215a63039606a6fcf55d9afe1cc76e3a5e27b9db58cdbf6"}, ] [package.dependencies] -botocore = ">=1.38.29,<1.39.0" +botocore = ">=1.38.32,<1.39.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.13.0,<0.14.0" @@ -679,15 +692,15 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.38.29" +version = "1.38.32" description = "Low-level, data-driven core of boto 3." optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"bedrock\"" files = [ - {file = "botocore-1.38.29-py3-none-any.whl", hash = "sha256:4d623f54326eb66d1a633f0c1780992c80f3db317a91c9afe31d5c700290621e"}, - {file = "botocore-1.38.29.tar.gz", hash = "sha256:98c42b1bbb52f4086282e7db8aa724c9cb0f7278b7827d6736d872511c856e4f"}, + {file = "botocore-1.38.32-py3-none-any.whl", hash = "sha256:64ab919a5d8b74dd73eaac1f978d0e674d11ff3bbe8815c3d2982477be9a082c"}, + {file = "botocore-1.38.32.tar.gz", hash = "sha256:0899a090e352cb5eeaae2c7bb52a987b469d23912c7ece86664dfb5c2e074978"}, ] [package.dependencies] @@ -1533,15 +1546,15 @@ files = [ [[package]] name = "e2b" -version = "1.5.0" +version = "1.5.1" description = "E2B SDK that give agents cloud environments" optional = true python-versions = "<4.0,>=3.9" groups = ["main"] markers = "extra == \"cloud-tool-sandbox\"" files = [ - {file = "e2b-1.5.0-py3-none-any.whl", hash = "sha256:875a843d1d314a9945e24bfb78c9b1b5cac7e2ecb1e799664d827a26a0b2276a"}, - {file = "e2b-1.5.0.tar.gz", hash = "sha256:905730eea5c07f271d073d4b5d2a9ef44c8ac04b9b146a99fa0235db77bf6854"}, + {file = "e2b-1.5.1-py3-none-any.whl", hash = "sha256:f000259b9aeaf802a174e4514d5c536d82c2c39970161a969a8c7b45510047c3"}, + {file = "e2b-1.5.1.tar.gz", hash = "sha256:db1e7dfec60534fd2b94638a8a76cfed8b2a44be203758e7d61e04a5c607801a"}, ] [package.dependencies] @@ -1555,15 +1568,15 @@ typing-extensions = ">=4.1.0" [[package]] name = "e2b-code-interpreter" -version = "1.5.0" +version = "1.5.1" description = "E2B Code Interpreter - Stateful code execution" optional = true python-versions = "<4.0,>=3.9" groups = ["main"] markers = "extra == \"cloud-tool-sandbox\"" files = [ - {file = "e2b_code_interpreter-1.5.0-py3-none-any.whl", hash = "sha256:299f5641a3754264a07f8edc3cccb744d6b009f10dc9285789a9352e24989a9b"}, - {file = "e2b_code_interpreter-1.5.0.tar.gz", hash = "sha256:cd6028b6f20c4231e88a002de86484b9d4a99ea588b5be183b9ec7189a0f3cf6"}, + {file = "e2b_code_interpreter-1.5.1-py3-none-any.whl", hash = "sha256:c8ee6f77bcb9c53422df336abbd37d5bf6318c3967b87444b39e3428a54c5e08"}, + {file = "e2b_code_interpreter-1.5.1.tar.gz", hash = "sha256:e39485dd2ffb148a902e8c05c8f573feeb7ca87f8498f02a4db65630e76364e1"}, ] [package.dependencies] @@ -1783,54 +1796,54 @@ Werkzeug = ">=1.0.1" [[package]] name = "fonttools" -version = "4.58.1" +version = "4.58.2" description = "Tools to manipulate font files" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "fonttools-4.58.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ebd423034ac4f74196c1ae29f8ed3b862f820345acbf35600af8596ebf62573"}, - {file = "fonttools-4.58.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9dc36f4b4044d95e6fb358da4c3e6a5c07c9b6f4c1e8c396e89bee3b65dae902"}, - {file = "fonttools-4.58.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4b74d7bb84189fe264d56a544ac5c818f8f1e8141856746768691fe185b229"}, - {file = "fonttools-4.58.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa4fa41e9cb43f78881a5896d6e41b6a0ec54e9d68e7eaaff6d7a1769b17017"}, - {file = "fonttools-4.58.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91335202f19c9edc04f2f6a7d9bb269b0a435d7de771e3f33c3ea9f87f19c8d4"}, - {file = "fonttools-4.58.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6b0ec2171e811a0d9e467225dc06b0fac39a84b4704f263c2d538c3c67b99b2"}, - {file = "fonttools-4.58.1-cp310-cp310-win32.whl", hash = "sha256:a788983d522d02a9b457cc98aa60fc631dabae352fb3b30a56200890cd338ca0"}, - {file = "fonttools-4.58.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8c848a2d5961d277b85ac339480cecea90599059f72a42047ced25431e8b72a"}, - {file = "fonttools-4.58.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9966e14729669bcfbb56f83b747a2397c4d97c6d4798cb2e2adc28f9388fa008"}, - {file = "fonttools-4.58.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64cc1647bbe83dea57f5496ec878ad19ccdba7185b0dd34955d3e6f03dc789e6"}, - {file = "fonttools-4.58.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:464f790ce681d08d1583df0735776aa9cb1999594bf336ddd0bf962c17b629ac"}, - {file = "fonttools-4.58.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c53c6a720ee70cc25746d511ba88c45c95ec510fd258026ed209b0b9e3ba92f"}, - {file = "fonttools-4.58.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6823a633bbce29cf3033508ebb54a433c473fb9833eff7f936bfdc5204fd98d"}, - {file = "fonttools-4.58.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5701fe66a1408c1974d2f78c00f964f8aad17cccbc32bc041e1b81421f31f448"}, - {file = "fonttools-4.58.1-cp311-cp311-win32.whl", hash = "sha256:4cad2c74adf9ee31ae43be6b0b376fdb386d4d50c60979790e32c3548efec051"}, - {file = "fonttools-4.58.1-cp311-cp311-win_amd64.whl", hash = "sha256:7ade12485abccb0f6b6a6e2a88c50e587ff0e201e48e0153dd9b2e0ed67a2f38"}, - {file = "fonttools-4.58.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f56085a65769dc0100822c814069327541db9c3c4f21e599c6138f9dbda75e96"}, - {file = "fonttools-4.58.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19c65a88e522c9f1be0c05d73541de20feada99d23d06e9b5354023cc3e517b0"}, - {file = "fonttools-4.58.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b01bb37006e97703300bfde7a73d1c7038574dd1df9d8d92ca99af151becf2ca"}, - {file = "fonttools-4.58.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d629dea240f0fc826d8bb14566e95c663214eece21b5932c9228d3e8907f55aa"}, - {file = "fonttools-4.58.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef0b33ff35421a04a638e736823c2dee9d200cdd275cfdb43e875ca745150aae"}, - {file = "fonttools-4.58.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4db9399ee633855c718fe8bea5eecbdc5bf3fdbed2648e50f67f8946b943ed1c"}, - {file = "fonttools-4.58.1-cp312-cp312-win32.whl", hash = "sha256:5cf04c4f73d36b30ea1cff091a7a9e65f8d5b08345b950f82679034e9f7573f4"}, - {file = "fonttools-4.58.1-cp312-cp312-win_amd64.whl", hash = "sha256:4a3841b59c67fa1f739542b05211609c453cec5d11d21f863dd2652d5a81ec9b"}, - {file = "fonttools-4.58.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68379d1599fc59569956a97eb7b07e0413f76142ac8513fa24c9f2c03970543a"}, - {file = "fonttools-4.58.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8631905657de4f9a7ae1e12186c1ed20ba4d6168c2d593b9e0bd2908061d341b"}, - {file = "fonttools-4.58.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2ecea7289061c2c71468723409a8dd6e70d1ecfce6bc7686e5a74b9ce9154fe"}, - {file = "fonttools-4.58.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b8860f8cd48b345bd1df1d7be650f600f69ee971ffe338c5bd5bcb6bdb3b92c"}, - {file = "fonttools-4.58.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7c9a0acdefcb8d7ccd7c59202056166c400e797047009ecb299b75ab950c2a9c"}, - {file = "fonttools-4.58.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1fac0be6be3e4309058e156948cb73196e5fd994268b89b5e3f5a26ee2b582"}, - {file = "fonttools-4.58.1-cp313-cp313-win32.whl", hash = "sha256:aed7f93a9a072f0ce6fb46aad9474824ac6dd9c7c38a72f8295dd14f2215950f"}, - {file = "fonttools-4.58.1-cp313-cp313-win_amd64.whl", hash = "sha256:b27d69c97c20c9bca807f7ae7fc7df459eb62994859ff6a2a489e420634deac3"}, - {file = "fonttools-4.58.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:927762f9fe39ea0a4d9116353251f409389a6b58fab58717d3c3377acfc23452"}, - {file = "fonttools-4.58.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:761ac80efcb7333c71760458c23f728d6fe2dff253b649faf52471fd7aebe584"}, - {file = "fonttools-4.58.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef910226f788a4e72aa0fc1c1657fb43fa62a4200b883edffdb1392b03fe86"}, - {file = "fonttools-4.58.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff2859ca2319454df8c26af6693269b21f2e9c0e46df126be916a4f6d85fc75"}, - {file = "fonttools-4.58.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:418927e888e1bcc976b4e190a562f110dc27b0b5cac18033286f805dc137fc66"}, - {file = "fonttools-4.58.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a907007a8b341e8e129d3994d34d1cc85bc8bf38b3a0be65eb14e4668f634a21"}, - {file = "fonttools-4.58.1-cp39-cp39-win32.whl", hash = "sha256:455cb6adc9f3419273925fadc51a6207046e147ce503797b29895ba6bdf85762"}, - {file = "fonttools-4.58.1-cp39-cp39-win_amd64.whl", hash = "sha256:2e64931258866df187bd597b4e9fff488f059a0bc230fbae434f0f112de3ce46"}, - {file = "fonttools-4.58.1-py3-none-any.whl", hash = "sha256:db88365d0962cd6f5bce54b190a4669aeed9c9941aa7bd60a5af084d8d9173d6"}, - {file = "fonttools-4.58.1.tar.gz", hash = "sha256:cbc8868e0a29c3e22628dfa1432adf7a104d86d1bc661cecc3e9173070b6ab2d"}, + {file = "fonttools-4.58.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4baaf34f07013ba9c2c3d7a95d0c391fcbb30748cb86c36c094fab8f168e49bb"}, + {file = "fonttools-4.58.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2e26e4a4920d57f04bb2c3b6e9a68b099c7ef2d70881d4fee527896fa4f7b5aa"}, + {file = "fonttools-4.58.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0bb956d9d01ea51368415515f664f58abf96557ba3c1aae4e26948ae7c86f29"}, + {file = "fonttools-4.58.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d40af8493c80ec17a1133ef429d42f1a97258dd9213b917daae9d8cafa6e0e6c"}, + {file = "fonttools-4.58.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:60b5cde1c76f6ded198da5608dddb1ee197faad7d2f0f6d3348ca0cda0c756c4"}, + {file = "fonttools-4.58.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8df6dc80ecc9033ca25a944ee5db7564fecca28e96383043fd92d9df861a159"}, + {file = "fonttools-4.58.2-cp310-cp310-win32.whl", hash = "sha256:25728e980f5fbb67f52c5311b90fae4aaec08c3d3b78dce78ab564784df1129c"}, + {file = "fonttools-4.58.2-cp310-cp310-win_amd64.whl", hash = "sha256:d6997ee7c2909a904802faf44b0d0208797c4d751f7611836011ace165308165"}, + {file = "fonttools-4.58.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:024faaf20811296fd2f83ebdac7682276362e726ed5fea4062480dd36aff2fd9"}, + {file = "fonttools-4.58.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2faec6e7f2abd80cd9f2392dfa28c02cfd5b1125be966ea6eddd6ca684deaa40"}, + {file = "fonttools-4.58.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520792629a938c14dd7fe185794b156cfc159c609d07b31bbb5f51af8dc7918a"}, + {file = "fonttools-4.58.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12fbc6e0bf0c75ce475ef170f2c065be6abc9e06ad19a13b56b02ec2acf02427"}, + {file = "fonttools-4.58.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:44a39cf856d52109127d55576c7ec010206a8ba510161a7705021f70d1649831"}, + {file = "fonttools-4.58.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5390a67c55a835ad5a420da15b3d88b75412cbbd74450cb78c4916b0bd7f0a34"}, + {file = "fonttools-4.58.2-cp311-cp311-win32.whl", hash = "sha256:f7e10f4e7160bcf6a240d7560e9e299e8cb585baed96f6a616cef51180bf56cb"}, + {file = "fonttools-4.58.2-cp311-cp311-win_amd64.whl", hash = "sha256:29bdf52bfafdae362570d3f0d3119a3b10982e1ef8cb3a9d3ebb72da81cb8d5e"}, + {file = "fonttools-4.58.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c6eeaed9c54c1d33c1db928eb92b4e180c7cb93b50b1ee3e79b2395cb01f25e9"}, + {file = "fonttools-4.58.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbe1d9c72b7f981bed5c2a61443d5e3127c1b3aca28ca76386d1ad93268a803f"}, + {file = "fonttools-4.58.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85babe5b3ce2cbe57fc0d09c0ee92bbd4d594fd7ea46a65eb43510a74a4ce773"}, + {file = "fonttools-4.58.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:918a2854537fcdc662938057ad58b633bc9e0698f04a2f4894258213283a7932"}, + {file = "fonttools-4.58.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b379cf05bf776c336a0205632596b1c7d7ab5f7135e3935f2ca2a0596d2d092"}, + {file = "fonttools-4.58.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99ab3547a15a5d168c265e139e21756bbae1de04782ac9445c9ef61b8c0a32ce"}, + {file = "fonttools-4.58.2-cp312-cp312-win32.whl", hash = "sha256:6764e7a3188ce36eea37b477cdeca602ae62e63ae9fc768ebc176518072deb04"}, + {file = "fonttools-4.58.2-cp312-cp312-win_amd64.whl", hash = "sha256:41f02182a1d41b79bae93c1551855146868b04ec3e7f9c57d6fef41a124e6b29"}, + {file = "fonttools-4.58.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:829048ef29dbefec35d95cc6811014720371c95bdc6ceb0afd2f8e407c41697c"}, + {file = "fonttools-4.58.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:64998c5993431e45b474ed5f579f18555f45309dd1cf8008b594d2fe0a94be59"}, + {file = "fonttools-4.58.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b887a1cf9fbcb920980460ee4a489c8aba7e81341f6cdaeefa08c0ab6529591c"}, + {file = "fonttools-4.58.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d74b9f6970cefbcda33609a3bee1618e5e57176c8b972134c4e22461b9c791"}, + {file = "fonttools-4.58.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec26784610056a770e15a60f9920cee26ae10d44d1e43271ea652dadf4e7a236"}, + {file = "fonttools-4.58.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed0a71d57dd427c0fb89febd08cac9b925284d2a8888e982a6c04714b82698d7"}, + {file = "fonttools-4.58.2-cp313-cp313-win32.whl", hash = "sha256:994e362b01460aa863ef0cb41a29880bc1a498c546952df465deff7abf75587a"}, + {file = "fonttools-4.58.2-cp313-cp313-win_amd64.whl", hash = "sha256:f95dec862d7c395f2d4efe0535d9bdaf1e3811e51b86432fa2a77e73f8195756"}, + {file = "fonttools-4.58.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f6ca4337e37d287535fd0089b4520cedc5666023fe4176a74e3415f917b570"}, + {file = "fonttools-4.58.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b269c7a783ec3be40809dc0dc536230a3d2d2c08e3fb9538d4e0213872b1a762"}, + {file = "fonttools-4.58.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1902d9b2b84cc9485663f1a72882890cd240f4464e8443af93faa34b095a4444"}, + {file = "fonttools-4.58.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a94a00ffacbb044729c6a5b29e02bf6f0e80681e9275cd374a1d25db3061328"}, + {file = "fonttools-4.58.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:25d22628f8b6b49b78666415f7cfa60c88138c24d66f3e5818d09ca001810cc5"}, + {file = "fonttools-4.58.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4bacb925a045e964a44bdeb9790b8778ce659605c7a2a39ef4f12e06c323406b"}, + {file = "fonttools-4.58.2-cp39-cp39-win32.whl", hash = "sha256:eb4bc19a3ab45d2b4bb8f4f7c60e55bec53016e402af0b6ff4ef0c0129193671"}, + {file = "fonttools-4.58.2-cp39-cp39-win_amd64.whl", hash = "sha256:c8d16973f8ab02a5a960afe1cae4db72220ef628bf397499aba8e3caa0c10e33"}, + {file = "fonttools-4.58.2-py3-none-any.whl", hash = "sha256:84f4b0bcfa046254a65ee7117094b4907e22dc98097a220ef108030eb3c15596"}, + {file = "fonttools-4.58.2.tar.gz", hash = "sha256:4b491ddbfd50b856e84b0648b5f7941af918f6d32f938f18e62b58426a8d50e2"}, ] [package.extras] @@ -2015,56 +2028,57 @@ files = [ [[package]] name = "gevent" -version = "24.11.1" +version = "25.5.1" description = "Coroutine-based network library" optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"dev\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "gevent-24.11.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:92fe5dfee4e671c74ffaa431fd7ffd0ebb4b339363d24d0d944de532409b935e"}, - {file = "gevent-24.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7bfcfe08d038e1fa6de458891bca65c1ada6d145474274285822896a858c870"}, - {file = "gevent-24.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7398c629d43b1b6fd785db8ebd46c0a353880a6fab03d1cf9b6788e7240ee32e"}, - {file = "gevent-24.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7886b63ebfb865178ab28784accd32f287d5349b3ed71094c86e4d3ca738af5"}, - {file = "gevent-24.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9ca80711e6553880974898d99357fb649e062f9058418a92120ca06c18c3c59"}, - {file = "gevent-24.11.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e24181d172f50097ac8fc272c8c5b030149b630df02d1c639ee9f878a470ba2b"}, - {file = "gevent-24.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1d4fadc319b13ef0a3c44d2792f7918cf1bca27cacd4d41431c22e6b46668026"}, - {file = "gevent-24.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:3d882faa24f347f761f934786dde6c73aa6c9187ee710189f12dcc3a63ed4a50"}, - {file = "gevent-24.11.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:351d1c0e4ef2b618ace74c91b9b28b3eaa0dd45141878a964e03c7873af09f62"}, - {file = "gevent-24.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5efe72e99b7243e222ba0c2c2ce9618d7d36644c166d63373af239da1036bab"}, - {file = "gevent-24.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d3b249e4e1f40c598ab8393fc01ae6a3b4d51fc1adae56d9ba5b315f6b2d758"}, - {file = "gevent-24.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81d918e952954675f93fb39001da02113ec4d5f4921bf5a0cc29719af6824e5d"}, - {file = "gevent-24.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9c935b83d40c748b6421625465b7308d87c7b3717275acd587eef2bd1c39546"}, - {file = "gevent-24.11.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff96c5739834c9a594db0e12bf59cb3fa0e5102fc7b893972118a3166733d61c"}, - {file = "gevent-24.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d6c0a065e31ef04658f799215dddae8752d636de2bed61365c358f9c91e7af61"}, - {file = "gevent-24.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:97e2f3999a5c0656f42065d02939d64fffaf55861f7d62b0107a08f52c984897"}, - {file = "gevent-24.11.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a3d75fa387b69c751a3d7c5c3ce7092a171555126e136c1d21ecd8b50c7a6e46"}, - {file = "gevent-24.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:beede1d1cff0c6fafae3ab58a0c470d7526196ef4cd6cc18e7769f207f2ea4eb"}, - {file = "gevent-24.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85329d556aaedced90a993226d7d1186a539c843100d393f2349b28c55131c85"}, - {file = "gevent-24.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:816b3883fa6842c1cf9d2786722014a0fd31b6312cca1f749890b9803000bad6"}, - {file = "gevent-24.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b24d800328c39456534e3bc3e1684a28747729082684634789c2f5a8febe7671"}, - {file = "gevent-24.11.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a5f1701ce0f7832f333dd2faf624484cbac99e60656bfbb72504decd42970f0f"}, - {file = "gevent-24.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d740206e69dfdfdcd34510c20adcb9777ce2cc18973b3441ab9767cd8948ca8a"}, - {file = "gevent-24.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:68bee86b6e1c041a187347ef84cf03a792f0b6c7238378bf6ba4118af11feaae"}, - {file = "gevent-24.11.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d618e118fdb7af1d6c1a96597a5cd6ac84a9f3732b5be8515c6a66e098d498b6"}, - {file = "gevent-24.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2142704c2adce9cd92f6600f371afb2860a446bfd0be5bd86cca5b3e12130766"}, - {file = "gevent-24.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92e0d7759de2450a501effd99374256b26359e801b2d8bf3eedd3751973e87f5"}, - {file = "gevent-24.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca845138965c8c56d1550499d6b923eb1a2331acfa9e13b817ad8305dde83d11"}, - {file = "gevent-24.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:356b73d52a227d3313f8f828025b665deada57a43d02b1cf54e5d39028dbcf8d"}, - {file = "gevent-24.11.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:58851f23c4bdb70390f10fc020c973ffcf409eb1664086792c8b1e20f25eef43"}, - {file = "gevent-24.11.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1ea50009ecb7f1327347c37e9eb6561bdbc7de290769ee1404107b9a9cba7cf1"}, - {file = "gevent-24.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:ec68e270543ecd532c4c1d70fca020f90aa5486ad49c4f3b8b2e64a66f5c9274"}, - {file = "gevent-24.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9347690f4e53de2c4af74e62d6fabc940b6d4a6cad555b5a379f61e7d3f2a8e"}, - {file = "gevent-24.11.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8619d5c888cb7aebf9aec6703e410620ef5ad48cdc2d813dd606f8aa7ace675f"}, - {file = "gevent-24.11.1-cp39-cp39-win32.whl", hash = "sha256:c6b775381f805ff5faf250e3a07c0819529571d19bb2a9d474bee8c3f90d66af"}, - {file = "gevent-24.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c3443b0ed23dcb7c36a748d42587168672953d368f2956b17fad36d43b58836"}, - {file = "gevent-24.11.1-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:f43f47e702d0c8e1b8b997c00f1601486f9f976f84ab704f8f11536e3fa144c9"}, - {file = "gevent-24.11.1.tar.gz", hash = "sha256:8bd1419114e9e4a3ed33a5bad766afff9a3cf765cb440a582a1b3a9bc80c1aca"}, + {file = "gevent-25.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8e5a0fab5e245b15ec1005b3666b0a2e867c26f411c8fe66ae1afe07174a30e9"}, + {file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7b80a37f2fb45ee4a8f7e64b77dd8a842d364384046e394227b974a4e9c9a52"}, + {file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29ab729d50ae85077a68e0385f129f5b01052d01a0ae6d7fdc1824f5337905e4"}, + {file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80d20592aeabcc4e294fd441fd43d45cb537437fd642c374ea9d964622fad229"}, + {file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8ba0257542ccbb72a8229dc34d00844ccdfba110417e4b7b34599548d0e20e9"}, + {file = "gevent-25.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cad0821dff998c7c60dd238f92cd61380342c47fb9e92e1a8705d9b5ac7c16e8"}, + {file = "gevent-25.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:017a7384c0cd1a5907751c991535a0699596e89725468a7fc39228312e10efa1"}, + {file = "gevent-25.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:469c86d02fccad7e2a3d82fe22237e47ecb376fbf4710bc18747b49c50716817"}, + {file = "gevent-25.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:12380aba5c316e9ff53cc21d8ab80f4a91c0df3ada58f65d4f5eb2cf693db00e"}, + {file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f0694daab1a041b69a53f53c2141c12994892b2503870515cabe6a5dbd2a928"}, + {file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2797885e9aeffdc98e1846723e5aa212e7ce53007dbef40d6fd2add264235c41"}, + {file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cde6aaac36b54332e10ea2a5bc0de6a8aba6c205c92603fe4396e3777c88e05d"}, + {file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24484f80f14befb8822bf29554cfb3a26a26cb69cd1e5a8be9e23b4bd7a96e25"}, + {file = "gevent-25.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc7446895fa184890d8ca5ea61e502691114f9db55c9b76adc33f3086c4368"}, + {file = "gevent-25.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5b6106e2414b1797133786258fa1962a5e836480e4d5e861577f9fc63b673a5a"}, + {file = "gevent-25.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:bc899212d90f311784c58938a9c09c59802fb6dc287a35fabdc36d180f57f575"}, + {file = "gevent-25.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d87c0a1bd809d8f70f96b9b229779ec6647339830b8888a192beed33ac8d129f"}, + {file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b87a4b66edb3808d4d07bbdb0deed5a710cf3d3c531e082759afd283758bb649"}, + {file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f076779050029a82feb0cb1462021d3404d22f80fa76a181b1a7889cd4d6b519"}, + {file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb673eb291c19370f69295f7a881a536451408481e2e3deec3f41dedb7c281ec"}, + {file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1325ed44225c8309c0dd188bdbbbee79e1df8c11ceccac226b861c7d52e4837"}, + {file = "gevent-25.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fcd5bcad3102bde686d0adcc341fade6245186050ce14386d547ccab4bd54310"}, + {file = "gevent-25.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a93062609e8fa67ec97cd5fb9206886774b2a09b24887f40148c9c37e6fb71c"}, + {file = "gevent-25.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:2534c23dc32bed62b659ed4fd9e198906179e68b26c9276a897e04163bdde806"}, + {file = "gevent-25.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a022a9de9275ce0b390b7315595454258c525dc8287a03f1a6cacc5878ab7cbc"}, + {file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fae8533f9d0ef3348a1f503edcfb531ef7a0236b57da1e24339aceb0ce52922"}, + {file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b32d9c3b5294b39ea9060e20c582e49e1ec81edbfeae6cf05f8ad0829cb13d"}, + {file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b95815fe44f318ebbfd733b6428b4cb18cc5e68f1c40e8501dd69cc1f42a83d"}, + {file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d316529b70d325b183b2f3f5cde958911ff7be12eb2b532b5c301f915dbbf1e"}, + {file = "gevent-25.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6ba33c13db91ffdbb489a4f3d177a261ea1843923e1d68a5636c53fe98fa5ce"}, + {file = "gevent-25.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ee34b77c7553777c0b8379915f75934c3f9c8cd32f7cd098ea43c9323c2276"}, + {file = "gevent-25.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fa6aa0da224ed807d3b76cdb4ee8b54d4d4d5e018aed2478098e685baae7896"}, + {file = "gevent-25.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0bacf89a65489d26c7087669af89938d5bfd9f7afb12a07b57855b9fad6ccbd0"}, + {file = "gevent-25.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e30169ef9cc0a57930bfd8fe14d86bc9d39fb96d278e3891e85cbe7b46058a97"}, + {file = "gevent-25.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e72ad5f8d9c92df017fb91a1f6a438cfb63b0eff4b40904ff81b40cb8150078c"}, + {file = "gevent-25.5.1-cp39-cp39-win32.whl", hash = "sha256:e5f358e81e27b1a7f2fb2f5219794e13ab5f59ce05571aa3877cfac63adb97db"}, + {file = "gevent-25.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:b83aff2441c7d4ee93e519989713b7c2607d4510abe990cd1d04f641bc6c03af"}, + {file = "gevent-25.5.1-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:60ad4ca9ca2c4cc8201b607c229cd17af749831e371d006d8a91303bb5568eb1"}, + {file = "gevent-25.5.1.tar.gz", hash = "sha256:582c948fa9a23188b890d0bc130734a506d039a2e5ad87dae276a456cc683e61"}, ] [package.dependencies] cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} -greenlet = {version = ">=3.1.1", markers = "platform_python_implementation == \"CPython\""} +greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""} "zope.event" = "*" "zope.interface" = "*" @@ -2183,15 +2197,15 @@ examples = ["oauth2"] [[package]] name = "google-auth" -version = "2.40.2" +version = "2.40.3" description = "Google Authentication Library" optional = true python-versions = ">=3.7" groups = ["main"] markers = "extra == \"google\"" files = [ - {file = "google_auth-2.40.2-py2.py3-none-any.whl", hash = "sha256:f7e568d42eedfded58734f6a60c58321896a621f7c116c411550a4b4a13da90b"}, - {file = "google_auth-2.40.2.tar.gz", hash = "sha256:a33cde547a2134273226fa4b853883559947ebe9207521f7afc707efbf690f58"}, + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, ] [package.dependencies] @@ -2211,15 +2225,15 @@ urllib3 = ["packaging", "urllib3"] [[package]] name = "google-genai" -version = "1.18.0" +version = "1.19.0" description = "GenAI Python SDK" optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"google\"" files = [ - {file = "google_genai-1.18.0-py3-none-any.whl", hash = "sha256:3527bb93c8306e725401aca0a0a684610bbf1ef9aa61c2ed3333a695f43dc9af"}, - {file = "google_genai-1.18.0.tar.gz", hash = "sha256:242a02df3248e291f03e37019ce5a1c8a21a14ec245b59668c9f2b4d8965295e"}, + {file = "google_genai-1.19.0-py3-none-any.whl", hash = "sha256:a2955612e4af8c84f83eb43c1ce4e74e1b714732926d0705e639761938192466"}, + {file = "google_genai-1.19.0.tar.gz", hash = "sha256:66f5de78075781bfd9e423f1e3592e4240759dfe0ac42ac74a9dcb2c4f662e9d"}, ] [package.dependencies] @@ -2354,68 +2368,67 @@ uvloop = ["uvloop (>=0.18.0) ; platform_python_implementation == \"CPython\" and [[package]] name = "greenlet" -version = "3.2.2" +version = "3.2.3" description = "Lightweight in-process concurrent programming" optional = false python-versions = ">=3.9" groups = ["main"] markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or (extra == \"dev\" or extra == \"desktop\" or extra == \"all\") and platform_python_implementation == \"CPython\"" files = [ - {file = "greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6"}, - {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7"}, - {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c"}, - {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907"}, - {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f"}, - {file = "greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13"}, - {file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5"}, - {file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:00cd814b8959b95a546e47e8d589610534cfb71f19802ea8a2ad99d95d702057"}, - {file = "greenlet-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d0cb7d47199001de7658c213419358aa8937df767936506db0db7ce1a71f4a2f"}, - {file = "greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068"}, - {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce"}, - {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b"}, - {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3"}, - {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74"}, - {file = "greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe"}, - {file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e"}, - {file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6"}, - {file = "greenlet-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b"}, - {file = "greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330"}, - {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b"}, - {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e"}, - {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275"}, - {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65"}, - {file = "greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3"}, - {file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e"}, - {file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5"}, - {file = "greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec"}, - {file = "greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59"}, - {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf"}, - {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325"}, - {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5"}, - {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825"}, - {file = "greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d"}, - {file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf"}, - {file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708"}, - {file = "greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421"}, - {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418"}, - {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4"}, - {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763"}, - {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b"}, - {file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207"}, - {file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8"}, - {file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51"}, - {file = "greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240"}, - {file = "greenlet-3.2.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370"}, - {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59"}, - {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e"}, - {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa"}, - {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819"}, - {file = "greenlet-3.2.2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc"}, - {file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457"}, - {file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a8fa80665b1a29faf76800173ff5325095f3e66a78e62999929809907aca5659"}, - {file = "greenlet-3.2.2-cp39-cp39-win32.whl", hash = "sha256:6629311595e3fe7304039c67f00d145cd1d38cf723bb5b99cc987b23c1433d61"}, - {file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"}, - {file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"}, + {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00"}, + {file = "greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302"}, + {file = "greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba"}, + {file = "greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34"}, + {file = "greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163"}, + {file = "greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849"}, + {file = "greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36"}, + {file = "greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3"}, + {file = "greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141"}, + {file = "greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a"}, + {file = "greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4"}, + {file = "greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57"}, + {file = "greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322"}, + {file = "greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365"}, ] [package.extras] @@ -3420,20 +3433,20 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10" [[package]] name = "langchain-core" -version = "0.3.63" +version = "0.3.64" description = "Building applications with LLMs through composability" optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"external-tools\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "langchain_core-0.3.63-py3-none-any.whl", hash = "sha256:f91db8221b1bc6808f70b2e72fded1a94d50ee3f1dff1636fb5a5a514c64b7f5"}, - {file = "langchain_core-0.3.63.tar.gz", hash = "sha256:e2e30cfbb7684a5a0319f6cbf065fc3c438bfd1060302f085a122527890fb01e"}, + {file = "langchain_core-0.3.64-py3-none-any.whl", hash = "sha256:e844c425329d450cb3010001d86b61fd59a6a17691641109bae39322c85e27dd"}, + {file = "langchain_core-0.3.64.tar.gz", hash = "sha256:71b51bf77003eb57e74b8fa2a84ac380e24aa7357f173b51645c5834b9fc0d62"}, ] [package.dependencies] jsonpatch = ">=1.33,<2.0" -langsmith = ">=0.1.126,<0.4" +langsmith = ">=0.3.45,<0.4" packaging = ">=23.2,<25" pydantic = ">=2.7.4" PyYAML = ">=5.3" @@ -3458,15 +3471,15 @@ langchain-core = ">=0.3.51,<1.0.0" [[package]] name = "langsmith" -version = "0.3.44" +version = "0.3.45" description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." optional = true python-versions = ">=3.9" groups = ["main"] markers = "extra == \"external-tools\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "langsmith-0.3.44-py3-none-any.whl", hash = "sha256:fa57afc1a3b1688f8970a5082dae8c271fdbd611cee013d412921eef926fcd78"}, - {file = "langsmith-0.3.44.tar.gz", hash = "sha256:0a72dfe87aa2f464ebbfb94937f57101bed9e0b1d6d26401d5e422b0e8867b40"}, + {file = "langsmith-0.3.45-py3-none-any.whl", hash = "sha256:5b55f0518601fa65f3bb6b1a3100379a96aa7b3ed5e9380581615ba9c65ed8ed"}, + {file = "langsmith-0.3.45.tar.gz", hash = "sha256:1df3c6820c73ed210b2c7bc5cdb7bfa19ddc9126cd03fdf0da54e2e171e6094d"}, ] [package.dependencies] @@ -3489,14 +3502,14 @@ pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4,<14.0.0)"] [[package]] name = "letta-client" -version = "0.1.146" +version = "0.1.147" description = "" optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "letta_client-0.1.146-py3-none-any.whl", hash = "sha256:7af140fcfd065ec924eebc5bf3ae66aaa1447bddf3c4a63c3be27381f363feae"}, - {file = "letta_client-0.1.146.tar.gz", hash = "sha256:0864a65e59abd5e9762f4176af2687d968a4c757473019199f5c9cb7d74bc693"}, + {file = "letta_client-0.1.147-py3-none-any.whl", hash = "sha256:13a42dfec4beaf37da41b5a8f3e86f4fc7d80743e9a2980560b07c097d413fcb"}, + {file = "letta_client-0.1.147.tar.gz", hash = "sha256:d34135c913686a21bfcd5c1257aef3cf0a45e4f68bf12ca1d564553a0a0e3340"}, ] [package.dependencies] @@ -3525,14 +3538,14 @@ pydantic = ">=1.10" [[package]] name = "llama-cloud-services" -version = "0.6.28" +version = "0.6.30" description = "Tailored SDK clients for LlamaCloud services." optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_cloud_services-0.6.28-py3-none-any.whl", hash = "sha256:b7a526d0b0558f3912993bb1f60f2b84b9fc495024177d2eea98f87525c81fcc"}, - {file = "llama_cloud_services-0.6.28.tar.gz", hash = "sha256:be95ae9af0d6d4143950909693670565ce9e4935a267e474426c420ee00d86ee"}, + {file = "llama_cloud_services-0.6.30-py3-none-any.whl", hash = "sha256:4d5817a9841fc3ba3409865c52d082090f4ef827931f0e5e4a89f5818c0d4e36"}, + {file = "llama_cloud_services-0.6.30.tar.gz", hash = "sha256:2cb5004d13127aac52888ae9b3d70f899d598633520b2a2542bb62682d08d776"}, ] [package.dependencies] @@ -3588,20 +3601,20 @@ openai = ">=1.14.0" [[package]] name = "llama-index-cli" -version = "0.4.2" +version = "0.4.3" description = "llama-index cli" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_index_cli-0.4.2-py3-none-any.whl", hash = "sha256:c8a842d68fdd76eeb12b6af259e2934666ee783f5c40d90c944e101f4e432aa4"}, - {file = "llama_index_cli-0.4.2.tar.gz", hash = "sha256:c60c5becc412b9d5d4eb58cbb8efd41363e45cb866f21a65c887f8fee9276443"}, + {file = "llama_index_cli-0.4.3-py3-none-any.whl", hash = "sha256:f0af55ce4b90e5a2466e394b88f4ac6085ea3e6286019bc3f49de6673ce2238d"}, + {file = "llama_index_cli-0.4.3.tar.gz", hash = "sha256:dae8183a10551bbd89686b94ed294a6cf44633c3dd6285c4f991c85031b7a55f"}, ] [package.dependencies] -llama-index-core = ">=0.12.0,<0.13.0" -llama-index-embeddings-openai = ">=0.3.0,<0.4.0" -llama-index-llms-openai = ">=0.4.0,<0.5.0" +llama-index-core = ">=0.12.0,<0.13" +llama-index-embeddings-openai = ">=0.3.1,<0.4" +llama-index-llms-openai = ">=0.4.0,<0.5" [[package]] name = "llama-index-core" @@ -3659,14 +3672,14 @@ openai = ">=1.1.0" [[package]] name = "llama-index-indices-managed-llama-cloud" -version = "0.7.3" +version = "0.7.4" description = "llama-index indices llama-cloud integration" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_index_indices_managed_llama_cloud-0.7.3-py3-none-any.whl", hash = "sha256:e2dab5d389f85e433bcea0cd9a4359f3918dcb9ec3a63728d1df64732a541ef4"}, - {file = "llama_index_indices_managed_llama_cloud-0.7.3.tar.gz", hash = "sha256:30ebde042cd95feff6f29df25da66a71a0d9b68bd1d6a48fabd70039cac8a629"}, + {file = "llama_index_indices_managed_llama_cloud-0.7.4-py3-none-any.whl", hash = "sha256:1d0ff874250c76615d0563409ebd887c5aac824382447054869a6be6335656bd"}, + {file = "llama_index_indices_managed_llama_cloud-0.7.4.tar.gz", hash = "sha256:f014ba41b56d4aefe346647770734bc914a1fc8f77bf508d8eaf0e2089189ec8"}, ] [package.dependencies] @@ -3675,14 +3688,14 @@ llama-index-core = ">=0.12.0,<0.13" [[package]] name = "llama-index-llms-openai" -version = "0.4.2" +version = "0.4.3" description = "llama-index llms openai integration" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_index_llms_openai-0.4.2-py3-none-any.whl", hash = "sha256:b168caba0051bd52ccb66888fe13da03899ec8add24cd91562f7e1c7e4fbe832"}, - {file = "llama_index_llms_openai-0.4.2.tar.gz", hash = "sha256:fec1f63e76fa17677cd4a1e869c58bacf92f1c5b23b763b7d8a563098a972cef"}, + {file = "llama_index_llms_openai-0.4.3-py3-none-any.whl", hash = "sha256:ff4bd4c6973afc46af755cbb5d13971c4dfab0313ac5168228f1442ac2f4ccfb"}, + {file = "llama_index_llms_openai-0.4.3.tar.gz", hash = "sha256:d51a77f9374e49ed2a8dee35247cee5cb854624560032be1a5f116a503484b36"}, ] [package.dependencies] @@ -3741,20 +3754,20 @@ llama-index-program-openai = ">=0.3.0,<0.4" [[package]] name = "llama-index-readers-file" -version = "0.4.8" +version = "0.4.9" description = "llama-index readers file integration" optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_index_readers_file-0.4.8-py3-none-any.whl", hash = "sha256:6cc64c70c3a9f5b146200a68c15b742e6f0d13b308a1c0dc6bb919e3b5fd4275"}, - {file = "llama_index_readers_file-0.4.8.tar.gz", hash = "sha256:d2821b164c11453b7993456355a6c544631a0ae8656a646df9fc009f16cfb99f"}, + {file = "llama_index_readers_file-0.4.9-py3-none-any.whl", hash = "sha256:71f1d0d2ea22012bf233ed4afb9b9b6b2f098d26286e38ed82e3c4af685e07bd"}, + {file = "llama_index_readers_file-0.4.9.tar.gz", hash = "sha256:b705fd42a2875af03d343c66b28138471800cd13a67efcc48ae2983b39d816c7"}, ] [package.dependencies] beautifulsoup4 = ">=4.12.3,<5" llama-index-core = ">=0.12.0,<0.13" -pandas = "*" +pandas = "<2.3.0" pypdf = ">=5.1.0,<6" striprtf = ">=0.0.26,<0.0.27" @@ -3779,30 +3792,30 @@ llama-parse = ">=0.5.0" [[package]] name = "llama-parse" -version = "0.6.28" +version = "0.6.30" description = "Parse files into RAG-Optimized formats." optional = false python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "llama_parse-0.6.28-py3-none-any.whl", hash = "sha256:b87135c45f9d6a56573b491b39df7ab4c72d6a57df8e5c7bb361b12d42509086"}, - {file = "llama_parse-0.6.28.tar.gz", hash = "sha256:6cee752dc93a5ead07c7da8c89839f2d3bcdab676603fde61fd12ac2d08227d6"}, + {file = "llama_parse-0.6.30-py3-none-any.whl", hash = "sha256:f5969510cf01c2fda9832acb32086dac781729bee5768c21ad9b444420173948"}, + {file = "llama_parse-0.6.30.tar.gz", hash = "sha256:2506802bc7f3974c75d91444387b0ee22c3a91828cd19da0dd9ea327c9f47a79"}, ] [package.dependencies] -llama-cloud-services = ">=0.6.28" +llama-cloud-services = ">=0.6.30" [[package]] name = "locust" -version = "2.37.7" +version = "2.37.9" description = "Developer-friendly load testing framework" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"dev\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "locust-2.37.7-py3-none-any.whl", hash = "sha256:925d78f4834d6434ec9a9c4f3984228f762d2c2d95b2804e9b0e9fc856f2d3b5"}, - {file = "locust-2.37.7.tar.gz", hash = "sha256:9421ff51ce023a5ddff74f2544a13310f7929ca02452be2663130ffab26585a1"}, + {file = "locust-2.37.9-py3-none-any.whl", hash = "sha256:e17da439f3a252d1fb6d4c34daf00d7e8b87e99d833a32e8a79f4f8ebb07767d"}, + {file = "locust-2.37.9.tar.gz", hash = "sha256:e43673b594ec5ecde4f9ba6e0d5c66c00d7c0ae93591951abe83e8d186c67175"}, ] [package.dependencies] @@ -3810,9 +3823,9 @@ configargparse = ">=1.7.1" flask = ">=2.0.0" flask-cors = ">=3.0.10" flask-login = ">=0.6.3" -gevent = ">=24.10.1,<25.0.0" +gevent = ">=24.10.1,<26.0.0" geventhttpclient = ">=2.3.1" -locust-cloud = ">=1.23.0" +locust-cloud = ">=1.23.1" msgpack = ">=1.0.0" psutil = ">=5.9.1" pywin32 = {version = "*", markers = "sys_platform == \"win32\""} @@ -3828,21 +3841,22 @@ werkzeug = ">=2.0.0" [[package]] name = "locust-cloud" -version = "1.23.0" +version = "1.23.1" description = "Locust Cloud" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"dev\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "locust_cloud-1.23.0-py3-none-any.whl", hash = "sha256:936cc4feb0b0dd89e113f6318a1205f318ed49a9df4e0feca8d40f2d9b353d30"}, - {file = "locust_cloud-1.23.0.tar.gz", hash = "sha256:4038a09eda858b483ced20f5cb82caf3f866244c2c7864e0da5c32722b97f532"}, + {file = "locust_cloud-1.23.1-py3-none-any.whl", hash = "sha256:11677895c6ed6d0beef1b425a6f04f10ea2cfcaeaefbf00a97fb3c9134296e54"}, + {file = "locust_cloud-1.23.1.tar.gz", hash = "sha256:a09161752b8c9a9205e97cef5223ee3ad967bc2d91c52d61952aaa3da6802a55"}, ] [package.dependencies] configargparse = ">=1.7.1" -gevent = ">=24.10.1,<25.0.0" +gevent = ">=24.10.1,<26.0.0" platformdirs = ">=4.3.6,<5.0.0" +python-engineio = ">=4.12.2" python-socketio = {version = "5.13.0", extras = ["client"]} tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} @@ -4079,14 +4093,14 @@ traitlets = "*" [[package]] name = "mcp" -version = "1.9.2" +version = "1.9.3" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "mcp-1.9.2-py3-none-any.whl", hash = "sha256:bc29f7fd67d157fef378f89a4210384f5fecf1168d0feb12d22929818723f978"}, - {file = "mcp-1.9.2.tar.gz", hash = "sha256:3c7651c053d635fd235990a12e84509fe32780cd359a5bbef352e20d4d963c05"}, + {file = "mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9"}, + {file = "mcp-1.9.3.tar.gz", hash = "sha256:587ba38448e81885e5d1b84055cfcc0ca56d35cd0c58f50941cab01109405388"}, ] [package.dependencies] @@ -5987,15 +6001,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-engineio" -version = "4.12.1" +version = "4.12.2" description = "Engine.IO server and client for Python" optional = true python-versions = ">=3.6" groups = ["main"] markers = "extra == \"dev\" or extra == \"desktop\" or extra == \"all\"" files = [ - {file = "python_engineio-4.12.1-py3-none-any.whl", hash = "sha256:9ec20d7900def0886fb9621f86fd1f05140d407f8d4e6a51bef0cfba2d112ff7"}, - {file = "python_engineio-4.12.1.tar.gz", hash = "sha256:9f2b5a645c416208a9c727254316d487252493de52bee0ff70dc29ca9210397e"}, + {file = "python_engineio-4.12.2-py3-none-any.whl", hash = "sha256:8218ab66950e179dfec4b4bbb30aecf3f5d86f5e58e6fc1aa7fde2c698b2804f"}, + {file = "python_engineio-4.12.2.tar.gz", hash = "sha256:e7e712ffe1be1f6a05ee5f951e72d434854a32fcfc7f6e4d9d3cae24ec70defa"}, ] [package.dependencies] @@ -6321,6 +6335,27 @@ files = [ [package.dependencies] prompt_toolkit = ">=2.0,<4.0" +[[package]] +name = "redis" +version = "6.2.0" +description = "Python client for Redis database and key-value store" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"redis\" or extra == \"all\"" +files = [ + {file = "redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e"}, + {file = "redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] + [[package]] name = "referencing" version = "0.36.2" @@ -7063,14 +7098,14 @@ files = [ [[package]] name = "tavily-python" -version = "0.7.3" +version = "0.7.5" description = "Python wrapper for the Tavily API" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "tavily_python-0.7.3-py3-none-any.whl", hash = "sha256:ed92e49e5be75a790ff7e36571bdb2982f179b34ca19f91d764ac7368219a8e5"}, - {file = "tavily_python-0.7.3.tar.gz", hash = "sha256:f091be8bfed28ab42ff239cf2c46f2ab3715071bf19951900157b3e4a60151c4"}, + {file = "tavily_python-0.7.5-py3-none-any.whl", hash = "sha256:e64660977c1b96df8c3327e8ff8ed626d5ca3178c52ee167ffa543731642f221"}, + {file = "tavily_python-0.7.5.tar.gz", hash = "sha256:f56aac136fbebeb2c8068fd6261aaa15f31d7da5c68e098ef7d6269ad032f842"}, ] [package.dependencies] @@ -8147,7 +8182,7 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\ cffi = ["cffi (>=1.11)"] [extras] -all = ["autoflake", "black", "docker", "fastapi", "granian", "isort", "langchain", "langchain-community", "locust", "pexpect", "pg8000", "pgvector", "pre-commit", "psycopg2", "psycopg2-binary", "pyright", "pytest-asyncio", "pytest-order", "uvicorn", "uvloop", "wikipedia"] +all = ["autoflake", "black", "docker", "fastapi", "granian", "isort", "langchain", "langchain-community", "locust", "pexpect", "pg8000", "pgvector", "pre-commit", "psycopg2", "psycopg2-binary", "pyright", "pytest-asyncio", "pytest-order", "redis", "uvicorn", "uvloop", "wikipedia"] bedrock = ["boto3"] cloud-tool-sandbox = ["e2b-code-interpreter"] desktop = ["docker", "fastapi", "langchain", "langchain-community", "locust", "pg8000", "pgvector", "psycopg2", "psycopg2-binary", "pyright", "uvicorn", "wikipedia"] @@ -8157,10 +8192,11 @@ external-tools = ["docker", "langchain", "langchain-community", "wikipedia"] google = ["google-genai"] postgres = ["asyncpg", "pg8000", "pgvector", "psycopg2", "psycopg2-binary"] qdrant = ["qdrant-client"] +redis = ["redis"] server = ["fastapi", "uvicorn"] tests = ["wikipedia"] [metadata] lock-version = "2.1" python-versions = "<3.14,>=3.10" -content-hash = "a3e22b2bcde514a289123db4457ea8ebb3a402c3a180afe563dfd6fd337c665d" +content-hash = "3c4253170cea8d2a7eec50fea32d33e8c155167d75d912cb5679d4e22ccc74c7" diff --git a/pyproject.toml b/pyproject.toml index ae42a642..87a914bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "letta" -version = "0.8.1" +version = "0.8.2" packages = [ {include = "letta"}, ] @@ -73,7 +73,7 @@ llama-index = "^0.12.2" llama-index-embeddings-openai = "^0.3.1" e2b-code-interpreter = {version = "^1.0.3", optional = true} anthropic = "^0.49.0" -letta_client = "^0.1.143" +letta_client = "^0.1.147" openai = "^1.60.0" opentelemetry-api = "1.30.0" opentelemetry-sdk = "1.30.0" @@ -96,11 +96,13 @@ async-lru = "^2.0.5" mistralai = "^1.8.1" uvloop = {version = "^0.21.0", optional = true} granian = {version = "^2.3.2", extras = ["uvloop", "reload"], optional = true} +redis = {version = "^6.2.0", optional = true} aiosqlite = "^0.21.0" [tool.poetry.extras] postgres = ["pgvector", "pg8000", "psycopg2-binary", "psycopg2", "asyncpg"] +redis = ["redis"] dev = ["pytest", "pytest-asyncio", "pexpect", "black", "pre-commit", "pyright", "pytest-order", "autoflake", "isort", "locust"] experimental = ["uvloop", "granian"] server = ["websockets", "fastapi", "uvicorn"] @@ -111,7 +113,7 @@ tests = ["wikipedia"] bedrock = ["boto3"] google = ["google-genai"] desktop = ["pgvector", "pg8000", "psycopg2-binary", "psycopg2", "pyright", "websockets", "fastapi", "uvicorn", "docker", "langchain", "wikipedia", "langchain-community", "locust"] -all = ["pgvector", "pg8000", "psycopg2-binary", "psycopg2", "pytest", "pytest-asyncio", "pexpect", "black", "pre-commit", "pyright", "pytest-order", "autoflake", "isort", "websockets", "fastapi", "uvicorn", "docker", "langchain", "wikipedia", "langchain-community", "locust", "uvloop", "granian"] +all = ["pgvector", "pg8000", "psycopg2-binary", "psycopg2", "pytest", "pytest-asyncio", "pexpect", "black", "pre-commit", "pyright", "pytest-order", "autoflake", "isort", "websockets", "fastapi", "uvicorn", "docker", "langchain", "wikipedia", "langchain-community", "locust", "uvloop", "granian", "redis"] [tool.poetry.group.dev.dependencies] diff --git a/tests/data/lines_1_to_100.txt b/tests/data/lines_1_to_100.txt new file mode 100644 index 00000000..b9ed43de --- /dev/null +++ b/tests/data/lines_1_to_100.txt @@ -0,0 +1,100 @@ +Line 1 +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +Line 10 +Line 11 +Line 12 +Line 13 +Line 14 +Line 15 +Line 16 +Line 17 +Line 18 +Line 19 +Line 20 +Line 21 +Line 22 +Line 23 +Line 24 +Line 25 +Line 26 +Line 27 +Line 28 +Line 29 +Line 30 +Line 31 +Line 32 +Line 33 +Line 34 +Line 35 +Line 36 +Line 37 +Line 38 +Line 39 +Line 40 +Line 41 +Line 42 +Line 43 +Line 44 +Line 45 +Line 46 +Line 47 +Line 48 +Line 49 +Line 50 +Line 51 +Line 52 +Line 53 +Line 54 +Line 55 +Line 56 +Line 57 +Line 58 +Line 59 +Line 60 +Line 61 +Line 62 +Line 63 +Line 64 +Line 65 +Line 66 +Line 67 +Line 68 +Line 69 +Line 70 +Line 71 +Line 72 +Line 73 +Line 74 +Line 75 +Line 76 +Line 77 +Line 78 +Line 79 +Line 80 +Line 81 +Line 82 +Line 83 +Line 84 +Line 85 +Line 86 +Line 87 +Line 88 +Line 89 +Line 90 +Line 91 +Line 92 +Line 93 +Line 94 +Line 95 +Line 96 +Line 97 +Line 98 +Line 99 +Line 100 \ No newline at end of file diff --git a/tests/data/long_test.txt b/tests/data/long_test.txt new file mode 100644 index 00000000..618bcc4c --- /dev/null +++ b/tests/data/long_test.txt @@ -0,0 +1,412 @@ +testEnrico Letta (Italian: [enˈriːko ˈlɛtta]; born 20 August 1966) is an Italian politician who served as Prime Minister of Italy from April 2013 to February 2014, leading a grand coalition of centre-left and centre-right parties.[1] He was the leader of the Democratic Party (PD) from March 2021 to March 2023.[2] + +After working as an academic, Letta entered politics in 1998 when he was appointed to the Cabinet as Minister for the Community Policies, a role he held until 1999 when he was promoted to become Minister of Industry, Commerce, and Crafts. In 2001, he left the Cabinet upon his election to the Chamber of Deputies. From 2006 to 2008, he was appointed Secretary of the Council of Ministers.[3] In 2007, Letta was one of the senior founding members of the Democratic Party, and in 2009 was elected as its Deputy Secretary.[4] + +After the 2013 Italian general election produced an inconclusive result, and following negotiations between party leaders, President Giorgio Napolitano gave him the task of forming a national unity government (Letta Cabinet), composed of Letta's PD, the centre-right The People of Freedom (PdL), and the centrist Civic Choice, in order to mitigate the economic and social crises engulfing Italy as a result of the Great Recession. Following an agreement between parties, Letta resigned as PD Deputy Secretary and was appointed Prime Minister of Italy on 28 April 2013.[5][6] His government tried to promote economic recovery by securing a funding deal from the European Union to alleviate youth unemployment and abolished the party subsidies, something seen as a watershed moment for Italian politics, which for years had depended upon public funds.[7][8][9] Letta also faced the early stages of the 2015 European migrant crisis, including the 2013 Lampedusa migrant shipwreck, the deadliest shipwreck in the recent history of the Mediterranean Sea; in response, Letta implemented Operation Mare Nostrum to patrol the maritime borders and rescue migrants.[10] + +In November 2013, PdL leader Silvio Berlusconi attempted to withdraw his party's support from the government in order to bring about a change of prime minister; in response, all of the cabinet's centre-right ministers chose to leave the PdL and formed a new party, saying they wished to continue supporting Letta. Despite securing his position, the election in December 2013 of Matteo Renzi as PD secretary brought significant leadership tensions within the PD to public view. After several weeks of denying that he would seek a change, Renzi publicly challenged Letta for the position of prime minister on 13 February 2014. Letta quickly lost the support of his colleagues and resigned as prime minister on 22 February.[11] + +Following his resignation, Letta initially retired from politics, leaving Italy to accept appointment as dean of the School of International Affairs at Sciences Po in Paris.[12] In March 2021, the PD secretary Nicola Zingaretti resigned after growing tensions within the party.[13] Many prominent members of the party asked Letta to become the new leader; after a few days, Letta announced that he would return to Italy to accept the candidacy, and he was elected as new secretary by the national assembly on 14 March 2021.[14][15] On 4 October 2021, Letta was elected to the Chamber of Deputies for the Siena constituency.[16] He resigned on 20 December 2024.[17] to become Dean of IE University’s School of Politics, Economics and Global Affairs in Madrid, Spain.[18] + +Early life and education +Letta was born in Pisa, Tuscany, to Giorgio Letta, an Abruzzo-born professor of mathematics who taught probability theory at the University of Pisa, member of the Lincean Academy and of the National Academy of the Sciences, and Anna Banchi, born in Sassari and raised in Porto Torres of Tuscan and Sardinian origins.[19][20] Born into a numerous family, uncles on his father's side include the centre-right politician Gianni Letta, a close advisor of Silvio Berlusconi, and the archaeologist Cesare Letta, while one of his paternal aunts, Maria Teresa Letta, served as vice president of the Italian Red Cross;[19] a maternal great-uncle is the poet and playwright Gian Paolo Bazzoni.[20] + +After spending part of his childhood in Strasbourg,[21] Letta completed his schooling in Italy at the liceo classico Galileo Galilei in Pisa.[22] He has a degree in political science, which he received from the University of Pisa and subsequently obtained a PhD at the Sant'Anna School of Advanced Studies, a Graduate School with university status.[23][n 1] + +From 2001 to 2003, Letta was professor at the University Carlo Cattaneo near Varese, and then he taught at the Sant'Anna School in Pisa in 2003 and at the HEC Paris in 2004.[25] + +Political career + +Letta in 2001 + This article is part of +a series about +Enrico Letta +Political positions +Minister for the Community Policies (1998–99) +Minister of Industry (1999–2001) +Prime Minister of Italy (2013–14) +Democratic Party Secretary (2021–present) +Political career +2007 leadership electionLettiani360 Association +Prime Minister of Italy +2013 electionLetta CabinetGrand coalitionEuropean debt crisisMigrant crisis2013 Lampedusa shipwreckOperation Mare NostrumResignation +Secretary of the Democratic Party +Leadership2021 by-election2022 presidential election2022 government crisis2022 general election +Academic career +Sciences PoJacques Delors Institute + +vte +Letta, a Catholic,[26] began his political career in the Christian Democracy (DC),[27] the dominant centrist and Roman Catholic party, which ruled Italy for almost fifty years. From 1991 to 1995, Letta was president of the Youth of the European People's Party,[23] the official youth wing of the European People's Party, a European political party founded by national-level Christian democratic parties, including the Italian DC; he used his presidency to help strengthen long-term connections among a variety of centrist parties in Europe, and has since remained a convinced supporter of the European Union and European integration.[28][29] + +During the Ciampi Cabinet headed by Carlo Azeglio Ciampi in 1993 and 1994, Letta worked as chief of staff for the minister of foreign affairs, Beniamino Andreatta; Andreatta, a left-leaning Christian Democrat economist with whom Letta had already been collaborating in a think tank known as Agenzia di Ricerche e Legislazione (AREL), played a highly influential role in Letta's political career.[23][28] + +Following the collapse of the DC in 1994, Letta joined its immediate successor, the Italian People's Party (PPI); after serving as secretary general of the Euro Committee within the Ministry of Treasury from 1996 to 1997, he became deputy secretary of the party in 1997 and 1998, when it was fully allied with the centre-left.[30] In 1998, after the fall of Romano Prodi's first government, Letta was appointed Minister for the Community Policies in cabinet of Massimo D'Alema at the age of 32, becoming the youngest cabinet minister in post-war Italy.[27] + +In 1999, Letta became Minister of Industry, Commerce and Crafts in the second government of D'Alema; a position that he held until 2001, serving also in the cabinet of Giuliano Amato.[31] During Amato's government he held the role of Minister of Foreign Trade too.[32] + +In the 2001 Italian general election, Letta was elected to the Chamber of Deputies as a member of Democracy is Freedom – The Daisy, a newly formed centrist formation to which the Italian People's Party had joined.[30][33] In the following year, he was appointed national responsible for the economic policies of The Daisy.[34] + +In 2004, Letta was elected member of the European Parliament, with nearly 179,000 votes, within The Olive Tree list,[35] joining the Alliance of Liberals and Democrats for Europe (ALDE) group. As MEP he became a member of the Committee on Economic and Monetary Affairs.[36] Letta served also in the committee for relations with the Maghreb countries and the Arab Maghreb Union.[37] + +In 2006, Letta was re-elected to the Chamber of Deputies and was appointed Secretary of the Council of Ministers in the second government of Romano Prodi, thereby succeeding his uncle Gianni Letta who had held the same position in the outgoing cabinet of Silvio Berlusconi. In this post, he became the closest advisor of Prime Minister Prodi, becoming one of the most influential politicians within the government. However, Prodi's government fell after only two years following tensions within its majority caused by the resignation of the Minister of Justice, Clemente Mastella.[38][39] Following the 2008 Italian general election, which saw a victory of the centre-right, Letta returned the post to his uncle, when the Berlusconi IV Cabinet was sworn in.[28][29] + +Leadership election candidacy +Main article: 2007 Democratic Party (Italy) leadership election +In 2007, together with other The Daisy's members, Letta joined the Democratic Party (PD), the new centre-left party, born from the union between The Daisy and the Democrats of the Left.[40][41] Having been a founding member of the party, Letta run in the first leadership election, which was held as an open primary. He announced his candidacy in July 2007 through a YouTube video.[42] A few weeks after the announcement, he compared the PD to Wikipedia, stating: "As in Wikipedia, even in the PD each of the hundreds of thousands of members must bring their own contributions, their own skills, which in certain fields are certainly more important than mine and those of the other leaders of the centre-left."[43] In support of his candidacy, Letta founded the 360 Association, a centrist and Christian leftist group, mainly composed by former members of The Daisy.[44][45] + +Letta's candidacy was supported by prominent members of the Italian centre-left, like Francesco Cossiga, Paolo De Castro, Gianni Pittella, Vito De Filippo and many other former members of The Daisy.[46] Moreover, Letta's faction was composed by politicians considered close to Prime Minister Romano Prodi, a Christian leftist professor and founding father of the Italian centre-left.[47][48] However, Letta had to face the politician who, more than any other, had worked to the formation of the Democratic Party and who was unanimously considered the future leader of the centre-left, Walter Veltroni, the incumbent Mayor of Rome.[49] In the primary election, Veltroni won by a landslide with 75.8% of votes, followed by the former Minister of Health Rosy Bindi with 12.9% and Letta with 11.0%.[50] + +After the primary election, Veltroni appointed Letta as the national responsible for labour. In May 2008, after the defeat in the 2008 election, Letta was appointed Shadow Minister of Labour and Social Policies in the second and last Shadow Cabinet formed in Italy.[51] + +Deputy Secretary of the Democratic Party + +Letta during a convention of his 360 Association in 2012 +During the leadership election of 2009, Letta supported the eventual winner, the social-democrat Pier Luigi Bersani, being appointed Deputy Secretary by the party's national convention.[52] + +In June 2010, Letta organized a three-day meeting in Verona, during which he met, within its association, entrepreneurs and key leaders of Lega Nord, the largest party in Veneto and eastern Lombardy.[53][54] An opinion poll among northern Democrats, released during the "Nord Camp", showed that they were keener on an alliance with Lega Nord than Berlusconi's The People of Freedom.[55] Letta was praised both by Roberto Maroni and Umberto Bossi.[56] + +In the 2013 Italian general election, the centre-left alliance Italy Common Good led by Bersani won a clear majority of seats in the Chamber of Deputies, thanks to a majority bonus that has effectively trebled the number of seats assigned to the winning party, while in the popular vote, it narrowly defeated the centre-right alliance of former prime minister Berlusconi. Close behind, the new anti-establishment Five Star Movement of comedian Beppe Grillo became the third-strongest force, clearly ahead of the centrist coalition of outgoing Prime Minister Mario Monti. In the Senate, no political group or party won an outright majority, resulting in a hung parliament.[57][58] + +On 20 April 2013, when Bersani resigned as Secretary after the candidates for President of the Republic Franco Marini and Romano Prodi were defeated in the presidential election, the whole leadership of the PD, including Deputy Secretary Letta, resigned their positions. + +Prime Minister of Italy +Main article: Letta Cabinet +Government formation +Following five inconclusive ballots for the 2013 Italian presidential election, incumbent president Giorgio Napolitano accepted to be re-elected at the Quirinal Palace.[59] Eventually, Napolitano reluctantly agreed to serve for another term in order to safeguard the continuity of the country's institutions.[60][61] Napolitano was easily re-elected on 20 April 2013, receiving 738 of the 1007 possible votes, and was sworn in on 22 April 2013 after a speech when he asked for constitutional and electoral reforms.[62] + + +Letta with President Giorgio Napolitano in Rome, 2013 +After his re-election, Napolitano immediately began consultations with the chairmen of the Chamber of Deputies, Senate and political forces, after the failure of the previous attempt with Bersani, and the establishment of a panel of experts by the President himself (dubbed as wise men by the press), in order to outline priorities and formulate an agenda to deal with the persistent economic hardship and growing unemployment. On 24 April 2013, Enrico Letta was invited to form a government by President Napolitano, following weeks of political deadlock.[63] + +On 27 April, Letta formally accepted the task of leading a grand coalition government, with support from the centre-left Democratic Party, the centre-right People of Freedom (PdL) of Silvio Berlusconi and the centrist Civic Choice of outgoing PM Mario Monti. The government he formed became the first in the history of the Italian Republic to include representatives of all the major coalitions that had run in the latest election. His close relationship with his uncle, Gianni Letta, one of Berlusconi's most trusted advisors, was perceived as a way of overcoming the bitter hostility between the two opposing factions.[21][64] Letta appointed Angelino Alfano, secretary of the People of Freedom, as his Deputy Prime Minister. The new government was formally sworn-in as on 28 April.[65] During the swearing ceremony, a man fired gunshots outside Chigi Palace and wounded two Carabinieri.[66] The attacker, Luigi Preiti, was stopped and arrested; he declared that he wanted to kill politicians or at least to hit a "symbol of politics" and that he was forced by despair being unemployed and recently divorced.[67] + +On 29 April, Letta's government won the confidence vote in the Chamber with 453 votes in favour, 152 against and 17 abstentions.[68] On the following day, he won the confidence vote in Senate too, with 233 votes in favour, 59 against 18 abstentions.[69] In his first speech in front of the Parliament, Letta stressed "necessity to restore decency, sobriety and a sense of honour"; he also advocated for a reduction of politics' costs.[70] + +Economic policies + +Prime Minister Letta in 2013 +During his premiership, Letta had to face a serious socio-economic crisis caused by the Great Recession and the subsequent European debt crisis. In 2013, one of the major problems of the country was the huge youth unemployment, which was valued around 40%.[71] To face this issue, on 14 June 2013, Letta scheduled a summit at Chigi Palace with the ministers of the economy, finance and labour of Italy, Germany, France and Spain, to agree on common EU policies for reducing unemployment.[8] After a few weeks, during a press conference at the conclusion of the Council of the European Union in Brussels, Letta announced that Italy would receive 1.5 billion euros in EU funds to fight youth unemployment.[9] + +On 31 May, the Council of Ministers resolved to sponsor a bill to abolish party subsidies, which was widely considered a revolution in Italian politics and political parties, which heavily depended on public funds.[7] On 4 June, Letta, within his Minister of Economic Development, Flavio Zanonato and his Minister of the Environment, Andrea Orlando, announced the receivership of Ilva, one of the largest steel makers in Europe, for a duration of 36 months, appointing Enrico Bondi as receiver.[72] + +On 15 June, the government approved the so-called "Action Decree" on hiring policies enabling economic recovery.[73] The decree was later approved by the Parliament between July and August 2013 with a confidence vote. The reform was harshly criticized by the anti-establishment Five Star Movement.[74] On 29 August, the government abolished IMU, the Italian tax on real estate introduced by the technocratic government of Mario Monti, for primary homes and for farm buildings .[75] + +Immigration policies +See also: Operation Mare Nostrum +As a result of the Libyan and Syrian Civil Wars, a major problem faced by Letta upon becoming prime minister in 2013 was the high levels of illegal immigration to Italy.[76] + +On 3 October 2013, a boat carrying migrants from Libya to Italy sank off the Italian island of Lampedusa. It was reported that the boat had sailed from Misrata, Libya, but that many of the migrants were originally from Eritrea, Somalia and Ghana.[77][78][79] An emergency response involving the Italian Coast Guard resulted in the rescue of 155 survivors.[78] On 12 October it was reported that the confirmed death toll after searching the boat was 359, but that further bodies were still missing;[80] a figure of "more than 360" deaths was later reported, becoming the deadliest shipwreck occurred in the Mediterranean Sea.[81] + +After the Lampedusa tragedy, Prime Minister Letta decided to strengthen the national patrolling of Sicilian channel by authorizing Operation Mare Nostrum, a military and humanitarian operation whose purpose was to patrol the maritime border and provide relief to migrants. This operation had two main purposes: to safeguard life at sea and to combat the illegal smuggling of migrants.[82] The operation brought at least 150,000 migrants to Europe, mainly from Africa and the Middle East.[83] The operation ended a few months after the end of his premiership, on 31 October 2014.[84] + +Foreign policies + +Letta with the U.S. President Barack Obama in the Oval Office +A strong pro-Europeanist politician, Letta built up close relations with other prominent European leaders like Angela Merkel, who was the first foreign leader he met, just a few days after his sworn in, on 30 April.[85] Letta also built a warm relationship with the French President François Hollande, with whom he shared a common view on austerity policies, considered outdated to face the economic crisis; Letta and Hollande often stressed the necessity to increase the public expenditures in investments.[86] + +On 17 and 18 June, Letta participated in his first G8 summit at Lough Erne in Northern Ireland.[87] During the summit, Letta had his first bilateral meeting with the President of the United States, Barack Obama. On 17 October, Letta was invited to the White House by President Obama, who stated that he had been really impressed by the Italian Prime Minister and his reforms plan.[88] + +On 5 and 6 September, Letta took part in the G20 summit in Saint Petersburg. The summit was focused on the aftermath of the Syrian civil war. Letta advocated for a diplomatic resolution of the crisis promoted by the United Nations.[89] On 25 September, during his speech in front of the United Nations General Assembly, Letta asked a deep reform of the UN Security Council.[90] + +September 2013 government crisis +On 28 September 2013, five ministers of The People of Freedom resigned on the orders of their leader, Silvio Berlusconi, pointing to the decision to postpone the decree that prevented the increase of the VAT from 21 to 22%, thus opening a government crisis.[91] On the following day, Letta had a meeting with President Napolitano to discuss the possible alternatives to solve the crisis. The head of State stressed that he would dissolve parliament only if there were no other possible alternatives.[92] + + +Letta with Angelino Alfano and Giorgio Napolitano in December 2013 +In the following days, dozens of members of PdL prepared to defy Berlusconi and vote in favour of the government, prompting him to announce that he would back the Prime Minister.[93][94][95] On 2 October, the government received 235 votes in favor and 70 against in the Senate, and 435 in favor and 162 against in the Chamber of Deputies.[96][97] Letta could thus continue his grand coalition government.[98] + +On 23 November, the Senate had to vote about the expulsion of Berlusconi from the Parliament, due to a conviction of tax fraud by the court of final instance and the Court of Cassation, which occurred a few months before.[99] Because he had been sentenced to a gross imprisonment for more than two years, the Senate voted to expel him from the Parliament, barring him from serving in any legislative office for six years.[100][101] + +After his expulsion from the Parliament, Berlusconi, who disbanded the PdL a few days before re-founding Forza Italia party, withdrew his support to the government. However, the interior minister Angelino Alfano did not follow his former leader, founding, along with other ministers and many members of the parliament, the New Centre-Right party, remaining in government.[102] The government later won key confidence votes in December 2013, with 173 votes in favour in the Senate and 350 in the Chamber.[103] + +On 26 January 2014, the Minister of Agriculture, Nunzia De Girolamo, resigned from her post due to claims of improper conduct linked to a scandal in the local healthcare system of her hometown, Benevento.[104][105] Her resignation was accepted by Letta on the following day, who took the ministerial role ad interim.[106] + +Resignation +On 8 December 2013, the Mayor of Florence, Matteo Renzi, won the Democratic Party leadership election by a landslide, immediately starting rumours about the possibility of becoming the new prime minister.[107] On 17 January 2014, while on air at Le invasioni barbariche on La7 TV channel, interviewed about tensions between him and Prime Minister Letta, Renzi tweeted the hashtag #enricostaisereno ("Enrico don't worry") to reassure his party colleague that he was not plotting anything against him.[108] + + +Letta with Matteo Renzi and President Napolitano in October 2013 +The growing criticism of the slow pace of Italian economic reform left Letta increasingly isolated within his own party.[109] At a PD's meeting on 13 February 2014, the Democratic Party leadership voted heavily in favour of Renzi's motion for "a new government, a new phase and a radical programme of reforms". Minutes after the party backed Renzi's proposal by 136 votes to 16, with two abstentions, Letta went to the Quirinal Palace, for a bilateral meeting with President Napolitano.[11] + +In an earlier speech, Renzi had paid tribute to Letta, saying that he did not intend to put him "on trial". But, without directly proposing himself as the next prime minister, he said the Eurozone's third-largest economy urgently needed "a new phase" and "radical programme" to push through badly-needed reforms. The motion he put forward made clear "the necessity and urgency of opening a new phase with a new executive". Speaking privately to party leaders, Renzi said that Italy was "at a crossroads" and faced either holding fresh elections or a new government without a return to the polls.[110] + +On 14 February, Letta resigned from the office of prime minister.[111] Following Letta's resignation, Renzi received the task of forming a new government from President Napolitano on 17 February,[112] and was formally sworn in as prime minister on 22 February.[113] + +Academic career + +Letta speaking at the Jacques Delors Institute in 2016 +In 2015, Letta resigned as a member of the Chamber of Deputies, after having voted against the new electoral law proposed by Prime Minister Renzi; at the same time, he announced that he would not renew the PD's membership.[114] + +In April 2015, Letta moved to Paris to teach at the Sciences Po, a higher education institute of political science. Since 1 September, he became dean of the Paris School of International Affairs (PSIA) of the same institute.[115] Along with his commitment to Sciences Po, he also had teaching periods at the University of Technology Sydney and the School of Global Policy and Strategy at the University of California, San Diego. In the same year, Letta launched Scuola di Politiche (School of Politics), a course of political science for young Italians.[116] + +In 2016, Letta supported the constitutional reform proposed by Renzi to reduce the powers of the Senate.[117] In the same year, along with the Jacques Delors Institute, he launched a school of political science focused on European issues, known as Académie Notre Europe.[118] In October 2017, he joined the new Comitè Action Publique 2022, a public commission for the reform of state and public administration in France which was strongly supported by President Emmanuel Macron.[119] + + +Letta with François Hollande and Jean-Claude Juncker in 2016 +In March 2019, following the victory of Nicola Zingaretti in the PD leadership election, Letta announced that he would re-join the party after four years.[120] In the same year, Letta also served on the advisory board of the annual Human Development Report of the United Nations Development Programme (UNDP), co-chaired by Thomas Piketty and Tharman Shanmugaratnam.[121] In 2020, he spoke in favour of the constitutional reform to reduce the number of MPs, considering it the first step to overcome perfect bicameralism.[122] + +Following his retirement from politics, Letta became advisor of many corporations and international organizations like Abertis, where he became member of the Board of Directors in 2016,[123][124] Amundi, in which he served as member of the Global Advisory Board since 2016,[125] the Eurasia Group, of which he has been Senior Advisor since 2016,[126] Publicis, where he served within the International Advisory Board since 2019[127] and Tikehau Capital, of which he became member of the International Advisory Board.[128] + +Letta is a member of many no-profit organizations like the International Gender Champions (IGC),[129] the British Council, Re-Imagine Europa,[130] the Trilateral Commission, in which he presided the European Group,[131] the Aspen Institute Italia, in which he served in the Executive Committee,[132] Associazione Italia ASEAN, of which he became chairman[133] and the Institut de Prospective Economique du Monde Méditerranéen (IPEMED).[134]. + +Letta was appointed Dean of IE School of Politics, Economics and Global Affairs. Letta will replace Manuel Muñiz, the current Provost of IE University and Charmain of the Board of IE New York College. He will join IE University on November 20.[135] + +Secretary of the Democratic Party + +Letta speaking at the European Parliament during the memorial for David Sassoli, in January 2022 +In January 2021, after the government crisis which forced Prime Minister Giuseppe Conte to resign, a national unity government led by Mario Draghi was formed.[136] In the midst of the formation of Draghi's government, Zingaretti was heavily criticized by the party's minority for his management of the crisis and strenuous support to Conte. On 4 March, after weeks of internal turmoil, Zingaretti announced his resignation as secretary, stating that he was "ashamed of the power struggles" within the party.[137] + +In the next days, many prominent members of the PD, including Zingaretti himself, but also former prime minister Paolo Gentiloni, former party secretary Dario Franceschini and President of Emilia-Romagna Stefano Bonaccini, publicly asked former Letta to become the new leader of the party.[138][139] Following an initial reluctance, Letta stated that he needed a few days to evaluate the option.[140] On 12 March, he officially accepted his candidacy as new party's leader.[141][142] On 14 March, the national assembly of the PD elected Letta secretary with 860 votes in favour, 2 against and 4 abstentions.[143][144] + +On 17 March, Letta appointed Peppe Provenzano and Irene Tinagli as his deputy secretaries.[145] On the following day, he appointed the party's new executive, composed of eight men and eight women.[146] Later that month, Letta forced the two Democratic leaders in Parliament, Graziano Delrio and Andrea Marcucci, to resign and proposed the election of two female leaders.[147] On 25 and 30 March, senators and deputies elected Simona Malpezzi and Debora Serracchiani as their leaders in the Senate and in the Chamber.[148][149] + + +Letta with Giuseppe Conte and the Finnish PM Sanna Marin in 2022 +In July 2021, Letta announced his intention to run for the Chamber of Deputies in the Siena constituency, which remained vacant after the resignation of Pier Carlo Padoan. On 4 October, Letta won the by-election with 49.9% of votes, returning to the Parliament after six years.[150] In the concurrent local elections, the PD and its allies won municipal elections in Milan, Bologna, Naples, Rome, Turin and many other major cities across the country.[151] + +As leader of the third political force in the parliament, Letta played an important role in the re-election of incumbent president Sergio Mattarella. On 23 January 2022, during Fabio Fazio's talk show Che tempo che fa, Letta stated that his favourable candidates for the presidency were Mario Draghi and Sergio Mattarella.[152] On the morning of 29 January, after the fall of all other possible candidacies, Letta asked the other leaders to follow "the Parliament's wisdom", referring to the massive support that Mattarella had received in the previous ballots.[153] On the same day, all the main parties asked Mattarella to serve for a second term. Despite his initial firm denial, Mattarella accepted the nomination[154] and was re-elected with 759 votes.[155] + +In July 2022, tensions arose within the governing majority, especially between Giuseppe Conte, leader of the Five Star Movement, and Prime Minister Draghi. Letta, who was trying to form a broad centre-left coalition with the M5S in the following election, was particularly critical of the possibility of a government crisis.[156] On 13 July, Conte announced that the M5S would revoke its support to the national unity government regarding the so-called decreto aiuti (English: aid decree), concerning economic stimulus to contrast the ongoing energy crisis, opening a political crisis within the majority.[157] On the following day, the M5S abstained and Prime Minister Draghi, despite having won the confidence vote, resigned.[158] However, the resignation was rejected by President Mattarella.[159] On the same day, Letta stressed that a government crisis needed to be officially opened in the Parliament, adding that "Italy deserved to stand with a strong personality like that of PM Draghi and the team that was around him."[160] However, on 21 July, Draghi resigned again after a new confidence vote in the Senate failed to pass with an absolute majority, following the defections of M5S, Lega, and Forza Italia;[161][162] A snap election was called for 25 September 2022.[163] + +After the 2022 general election, Enrico Letta conceded defeat and announced that he would not stand at the congress to elect the new party secretary.[164][165][166][167] He was succeeded by Elly Schlein, following the election on 26 February 2023.[168] + +Personal life +Letta is married to Gianna Fregonara, an Italian journalist, with whom he had three children, Giacomo, Lorenzo and Francesco.[169] + +Letta is known to be fond of listening to Dire Straits and playing Subbuteo;[170] he is also an avid supporter of A.C. Milan.[171] In addition to his native Italian, Letta speaks French and English fluently.[29] + +Electoral history +Election House Constituency Party Votes Result +2001 Chamber of Deputies Piedmont 1 DL –[a] check Elected +2004 European Parliament North-East Italy Ulivo 178,707 check Elected +2006 Chamber of Deputies Lombardy 1 Ulivo –[a] check Elected +2008 Chamber of Deputies Lombardy 2 PD –[a] check Elected +2013 Chamber of Deputies Marche PD –[a] check Elected +2021 Chamber of Deputies Siena PD 33,391 check Elected +2022 Chamber of Deputies Lombardy 1 PD –[a] check Elected + Elected in a closed list proportional representation system. +First-past-the-post elections +2021 Italian by-election (C): Siena +Candidate Party Votes % +Enrico Letta Centre-left coalition 33,391 49.9 +Tommaso Marrocchesi Marzi Centre-right coalition 25,303 37.8 +Others 8,191 12.3 +Total 66,885 100.0 +References + Quirinale, il governo di Letta giura davanti a Napolitano, Il Fatto Quotidiano + Letta eletto segretario: "Serve un nuovo Pd aperto, non partito del potere", Sky Tg24 + Enrico Letta, Enciclopedia Treccani + Italian Parliament Website LETTA Enrico – PD Retrieved 24 April 2013 + Nuovo governo, incarico a Enrico Letta. Napolitano: "I media cooperino", Il Fatto Quotidiano + "Letta: Grande coalizione, bisogna farsene una ragione". Archived from the original on 8 October 2016. Retrieved 28 January 2019. + Tre canali di finanziamento, più trasparenza. Ecco punto per punto il ddl del governo, Corriere della Sera + Vertice lavoro, Letta ai ministri europei: «Non c'è più tempo, si deve agire subito Scelta sciagurata guardare solo i conti» – Il Messaggero Archived 16 June 2013 at the Wayback Machine. Ilmessaggero.it. Retrieved on 24 August 2013. + Letta: all'Italia 1,5 miliardi per il lavoro. Grillo «poteva mandare tutto in vacca», Corriere della Sera + Letta: perché difendo Mare Nostrum, Avvenire + "Letta al Quirinale, si è dimesso – Top News". Retrieved 12 July 2016. + Enrico Letta, Sciences Po + Pd, Zingaretti si dimette. Dice addio il decimo segretario in 14 anni, Il Sole 24 Ore + Letta, il giorno della scelta. Zingaretti: rilancerà il Pd, il manifesto + Letta: "Non vi serve un nuovo segretario, ma un nuovo Pd", Huffington Post + Elezioni suppletive Siena: vince Letta, La Stampa + https://www.ansa.it/sito/notizie/politica/2024/12/20/in-aula-alla-camera-si-votano-le-dimissioni-di-enrico-letta_7a395834-ba1c-4567-bcfe-b3e3f499f045.html + Popova, Maria (1 October 2024). "Enrico Letta, new dean of the Faculty of Politics and Economics of the IE University of Segovia". Top Buzz Times. Retrieved 2 October 2024. + Motta, Nino (2 February 2013). "Un Letta per ogni stagione". Il Centro. + "Gli zii di Enrico Letta. Non solo Gianni: c'è anche Gian Paolo Bazzoni a Porto Torres". Sardinia Post. 25 April 2013. Retrieved 2 June 2013. + Winfield, Nicole (24 April 2013). "Enrico Letta Appointed Italian Prime Minister, Asked To Form Government". The Huffington Post. Retrieved 4 May 2013. + Letta, Enrico (2013). "Curriculum Vitae" (PDF). Archived from the original (PDF) on 11 June 2013. Retrieved 3 June 2013. + "Enrico Letta: la bio del giovane dalla grande esperienza". Huffington Post (in Italian). 24 August 2013. Retrieved 3 June 2013. + "Su esecutivo marchio scuola Sant'Anna: Pisa Letta si e' specializzato, Carrozza e' stato rettore" (in Italian). ANSA. 27 April 2013. Retrieved 11 June 2013. + Governo. Enrico Letta, l’allievo di Andreatta diventa presidente del Consiglio, Il Giornale + Enrico Marro (24 April 2013). "Chi è Enrico Letta? Quel giovane cattolico moderato, con agganci in tutto il Transatlantico. Nipote di Gianni. E fan di Mandela". Il Sole-24 Ore (in Italian). Milan. Retrieved 6 December 2022. + "Profile: Enrico Letta". BBC News. 24 April 2013. Retrieved 3 June 2013. + Povoledo, Elisabetta (28 April 2013). "An Italian Leader and a Political Acrobat". The New York Times. Retrieved 3 June 2013. + Dinmore, Guy (24 April 2013). "Italy's Enrico Letta a party loyalist and bridge-builder". Financial Times. + Sachelli, Orlando (24 April 2013). "Enrico Letta, il giovane Dc che deve far da paciere tra Pd e Pdl". Il Giornale (in Italian). Retrieved 7 June 2013. + Enrico Letta, Il Sole 24 Ore + DDL presentati dal Ministero del commercio con l'estero (Governo Amato-II), Parlamento + "Pisano, milanista, baby-ministro. Ecco chi è Enrico Letta, l'eterno "giovane" del Pd". Libero (in Italian). 24 April 2013. + Enrico Letta, Biografie Online + Elezioni Europee del 2004. Circoscrizione Italia Nord-Orientale, Dipartimento per gli Affari Interni + "European Parliament Website". European Parliament. Retrieved 7 May 2013. + Members of the European Parliament, European Parliament + "Mastella to Drop Support for Prodi, Favors Elections", Bloomberg, 21 January 2008. + "Italy PM in cabinet crisis talks", BBC News, 21 January 2008. + Vespa, Bruno (2010). Il Cuore e la Spada: Storia politica e romantica dell'Italia unita, 1861–2011. Mondadori. p. 650. ISBN 9788852017285. + Augusto, Giuliano (8 December 2013), "De profundis per il Pd", Rinascita, archived from the original on 1 March 2014 + Pd: Letta: «Mi candido». Video sul web, Corriere della Sera + Letta leader? Senza grinta, Il Fatto Quotidiano + "Associazione 360". Archived from the original on 20 June 2008. Retrieved 3 July 2008. + "Noi". Associazione Trecento Sessanta. Archived from the original on 14 March 2010. Retrieved 10 March 2010. + De Castro: "Candidatura di Letta grande occasione per coinvolgere la società Archived 27 September 2007 at the Wayback Machine, Enrico Letta + "DemocraticiPerLetta.info – Home". Archived from the original on 6 October 2008. Retrieved 3 July 2008. + "cantieredemocratico.it – - Notizie: Pd: per Veltroni tre liste nazionali". Archived from the original on 7 January 2009. Retrieved 3 July 2008. + "Rome Mayor Set to Win Left's Leadership". Associated Press. 14 October 2007. Retrieved 15 October 2007.[permanent dead link] + "Veltroni stravince con il 76% ma è la festa dei cittadini elettori". la Repubblica (in Italian). 14 October 2007. + Partito Democratico Archived 2008-04-30 at the Wayback Machine + "Pd, Bersani indica la rotta "Noi, partito dell'alternativa"". Quotidiano.net (in Italian). 9 September 2009. Retrieved 26 April 2013. + "Il Pd "sale" al Nord. E dialoga con Maroni". Archiviostorico.corriere.it. Retrieved 17 July 2014. + "Letta accoglie Maroni "Con la Lega si deve parlare"". Archiviostorico.corriere.it. Retrieved 17 July 2014. + "L' elettore del Nord: il Pdl? Meglio allearsi con la Lega". Archiviostorico.corriere.it. Retrieved 17 July 2014. + "Bossi loda Letta: giusto il dialogo sa che con noi si vince alle urne". Archiviostorico.corriere.it. Retrieved 17 July 2014. + "Italian election results: gridlock likely – as it happened". The Guardian. 26 February 2013. Retrieved 27 February 2013. + "Italy struggles with 'nightmare' election result". BBC News. 26 February 2013. Retrieved 27 February 2013. + "Italy crisis: President Giorgio Napolitano re-elected". BBC News. 20 April 2013. Retrieved 20 April 2013. + Mackenzie, James (20 April 2013). "Giorgio Napolitano, Italy's reluctant president". Bloomberg L.P. Retrieved 21 April 2013. + Napolitano, Giorgio; Scalfari, Eugenio (9 June 2013). "Napolitano si racconta a Scalfari: 'La mia vita, da comunista a Presidente'" (Video, at 59 min). La Repubblica (in Italian). Retrieved 9 June 2013. + The critical findings on electoral law echoed in the words that the head of state gave 22 April 2013 before the Electoral College that had re-elected him for a second term: Buonomo, Giampiero (2013). "Porcellum, premio di maggioranza a rischio". Golem Informazione. Archived from the original on 11 December 2019. Retrieved 11 March 2021. + Frye, Andrew (24 April 2013). "Letta Named Italian Prime Minister as Impasse Ends". Bloomberg. Retrieved 26 April 2013. + "Bridge-builder Enrico Letta seals Silvio Berlusconi deal". The Australian. 29 April 2013. Retrieved 8 June 2013. + Nasce il governo Letta, ora la fiducia. Il premier: «Sobria soddisfazione», Corriere della Sera + "New Italian 'grand coalition' government sworn in". BBC News. 28 April 2013. Retrieved 28 April 2013. + Sparatoria Palazzo Chigi: due carabinieri feriti. L’attentatore: "Puntavo ai politici", Il Fatto Quotidiano + Letta: «Abbiamo un'ultima possibilità. Basta debiti scaricati sulle future generazioni», Corriere della Sera + Governo Letta, fiducia anche al Senato, Corriere della Sera + Governo Letta, fiducia alla Camera: 453 sì, 153 no. Si astiene la Lega, Il Fatto Quotidiano + Disoccupazione giovanile, Bce: "Nel 2013 in Italia è arrivata vicina al 40%", Il Fatto Quotidiano + Ilva, firmato il decreto: Enrico Bondi commissario per 36 mesi, Il Fatto Quotidiano + Il Decreto del fare, misura per misura – Europa Quotidiano Archived 19 June 2013 at the Wayback Machine. Europaquotidiano.it (16 June 2013). Retrieved on 24 August 2013. + La Camera approva il «decreto del fare», Corriere della Sera + Abolizione IMU 2013, ecco cosa cambia per "prima casa", edilizia e terreni agricoli, EdilTecnico + Letta da Malta: " Orgoglio per l'operazione Mare Nostrum", Rai News + Pianigiani, Gaia (3 October 2013). "Scores of Migrants Dead After Boat Sinks Off Sicily". The New York Times. Siracusa. Retrieved 3 October 2013. + "Dozens of migrants die in Italy boat sinking near Lampedusa". BBC News. 3 October 2013. Retrieved 3 October 2013. + "Witness: Boat migrants used bottles to stay afloat". USA Today. 4 October 2013. Retrieved 4 October 2013. + "Mediterranean 'a cemetery' – Maltese PM Muscat". BBC News. 12 October 2013. Retrieved 12 October 2013. + "Lampedusa boat tragedy: Migrants 'raped and tortured'". BBC News. 8 November 2013. Retrieved 8 November 2013. + "Mare Nostrum Operation". Ministry of Defence of Italy. Retrieved 16 April 2015. + "IOM Applauds Italy's Life-Saving Mare Nostrum Operation: "Not a Migrant Pull Factor"". International Organization for Migration. 31 October 2014. Archived from the original on 16 April 2015. Retrieved 16 April 2015. + Ella Ide (31 October 2014). "Italy ignores pleas, ends boat migrant rescue operation". Yahoo! News. Retrieved 16 April 2015. + Letta, tour in Europa: vertice con Merkel. La cancelliera: «Italia sulla buona strada», Il Fatto Quotidiano + Ue, asse Letta-Hollande per la crescita, Corriere della Sera + G8, il debutto di Enrico Letta Prima l'incontro con Obama L'incognita Siria divide già – Quotidiano Net. Quotidiano.net. Retrieved on 24 August 2013. + Usa, Obama riceve Letta: "Italia sulla strada giusta, impressionato da premier", la Repubblica + Siria, Enrico Letta: "Una soluzione politica con l'Onu è ancora possibile. Strada stretta, ma fondamentale", Huffington Post + Letta a Wall Street: "Siamo affidabili". E all'Onu chiede riforma Consiglio sicurezza, la Repubblica + "Berlusconi fa dimettere ministri: è crisi. Letta: gesto folle per motivi personali". Repubblica.it. 28 September 2013. Retrieved 13 February 2014. + "Napolitano: "Verifico possibilità legislatura". Caos nel Pdl. Alfano: "No a estremismi"". Repubblica.it. 29 September 2013. Retrieved 13 February 2014. + Berlusconi U-turn secures Italian government survival + "Italian PM wins confidence vote after Berlusconi abandons revolt - as it happens". The Guardian. 2 October 2013. Archived from the original on 27 March 2023. + Italy crisis: PM Letta wins vote after Berlusconi U-turn + "Irrevocabili dimissioni ministri Pdl – Politica". ANSA.it. 28 September 2013. Retrieved 13 February 2014. + "Letta mercoledì a Camera e Senato – Politica". ANSA.it. 29 September 2013. Retrieved 13 February 2014. + "Berlusconi si arrende, Letta ottiene fiducia Napolitano: "Ora basta giochi al massacro"". Repubblica.it. 16 November 2013. Retrieved 13 February 2014. + Parks, Tim (24 August 2013). "Holding Italy Hostage". The New York Review of Books. Archived from the original on 25 October 2013. Retrieved 6 September 2013. + Italy's Senate expels ex-PM Silvio Berlusconi, BBC, 27 November 2013. Archived 30 November 2013 at the Wayback Machine + "Berlusconi vows to stay in politics as ban approaches". Reuters. 18 September 2013. Archived from the original on 14 October 2013. Retrieved 18 September 2013. + james mackenzie (3 December 2013). "Italy PM Letta to seek new confidence vote on December 11". The Star. Malaysia. Archived from the original on 7 December 2013. Retrieved 13 February 2014. + Letta incassa la fiducia, ma è bagarre in aula. E la Lega perde un pezzo, la Repubblica + James MacKenzie (26 January 2014). "Italy minister resigns, adding to headaches for government". Reuters. Rome. Retrieved 29 January 2014. + "Italy's agriculture minister resigns, blow to govt". Seattle Pi. 26 January 2014. Retrieved 29 January 2014. + "Premier accepts agriculture minister's resignation". La Gazzetta del Mezzogiorno. 27 January 2014. Archived from the original on 2 July 2015. Retrieved 29 January 2014. + Primarie PD 2013, Partito Democratico + Renzi: quando assicurava di non voler prendere il posto di Letta, Corriere della Sera + "Napolitano accepts Letta's resignation as Italian prime minister". Euronews. 14 February 2014. Archived from the original on 14 February 2014. Retrieved 14 February 2014. + Lizzy Davies in Rome. "Italian PM Enrico Letta to resign". The Guardian. Retrieved 13 February 2014. + Правительственный кризис в Италии: премьер Летта ушел в отставку (in Russian). RIA Novosti. 14 February 2014. Retrieved 14 February 2014. + "39 Year Old Matteo Renzi becomes, at 39, Youngest Italian Prime Minister". IANS. news.biharprabha.com. Retrieved 17 February 2014. + "Matteo Renzi sworn in as Italy's new PM in Rome ceremony". BBC. 22 February 2014. Retrieved 26 February 2014. + Enrico Letta si dimette da deputato: il discorso in aula e il lungo applauso della Camera, Huffington Post + "Enrico Letta, New Dean of PSIA". SciencesPo News. 21 April 2014. Retrieved 10 March 2017. + "Scuola di Politiche", Scuoladipolitiche.eu. Retrieved 4 February 2022. + Letta: «Italicum legge sbagliata. Ma al referendum io voterò Sì», Corriere della Sera + Letta battezza Académie Notre Europe: "Per creare una classe dirigente europea ed europeista", Huffington Post + Macron chiama Letta a far parte della Commissione per la riforma dello Stato, Il Giornale + Enrico Letta: "Dopo 5 anni riprendo la tessera del Pd. Mai più partito dell’antipatia", la Repubblica + 2019 Human Development Report Advisory Board Members United Nations Development Programme (UNDP). + Referendum, Letta: "Voterò Sì convintamente. Tutte le nostre proposte di riforma prevedevano lo stesso taglio. 630 deputati? Ne bastano 400", Il Fatto Quotidiano + "Abertis' Board of Directors appoints Luis Fortuño and Enrico Letta as new directors". Abertis.com. Archived from the original on 16 August 2018. Retrieved 16 August 2018. + Polizzi, Daniela. "Ai Benetton le autostrade spagnole Accordo Atlantia-Hochtief su Abertis". Corriere della Sera (in Italian). Retrieved 16 August 2018. + Amundi creates a Global Advisory Board with world-renowned experts in global economic and political issues Amundi, press release of 31 May 2016. + Former Italian Prime Minister Enrico Letta joins Eurasia Group as Senior Advisor Eurasia Group, press release of 8 March 2016. + Supervisory Board Publicis, press release of 7 March 2019. + International Advisory Board Archived 4 January 2021 at the Wayback Machine Tikehau Capital. + Members International Gender Champions (IGC). + Advisory Board Re-Imagine Europa. + Membership Trilateral Commission. + "Comitato Esecutivo". Aspen Institute Italia. Archived from the original on 9 October 2010. Retrieved 26 April 2013. + About Us Archived 25 November 2018 at the Wayback Machine Associazione Italia ASEAN. + Governance Institut de Prospective Economique du Monde Méditerranéen (IPEMED), Paris. + https://www.ie.edu/school-politics-economics-global-affairs/news/enrico-letta-former-italian-prime-minister-appointed-dean-ie-school-politics-economics-global-affairs/ + "Mario Draghi sworn in as Italy's new prime minister". BBC News. 13 February 2021. + "Zingaretti quits as chief of Italy's Democratic party over infighting". Financial Times. 4 March 2021. Archived from the original on 7 March 2021. Retrieved 12 March 2021. + "Zingaretti: "Letta può rendere il Pd protagonista indiscusso della democrazia italiana"" (in Italian). Il Foglio. 12 March 2021. + ""Dobbiamo salvare il Pd". Così Franceschini lavora per Letta" (in Italian). Il Foglio. 9 March 2021. + "Letta takes time to consider taking lead of PD – English". ANSA.it. 10 March 2021. Retrieved 10 March 2021. + "Pd, Letta sarà il nuovo segretario. Il tweet: "Io ci sono, chiedo voto sulla base delle mie parole". Ecco il programma dell'Assemblea di domenica" (in Italian). La Repubblica. 12 March 2021. + "Enrico Letta, Italian ex-PM, poised for political comeback". Politico Europe. 12 March 2021. + Pd, Letta segretario con 860 sì: "Serve un nuovo Pd. Priorità a lavoro, giovani e donne". Promette battaglia sul voto ai sedicenni e Ius soli. E sulle alleanze: "Sentirò 5S e Renzi", la Repubblica + First speech as candidate secretary of the Italian Partito Democratico (in Italian). Archived from the original on 13 December 2021. + Provenzano e Tinagli, il cacciavite di Letta funziona, Huffington Post + Pd, Letta nomina la nuova segreteria del partito: sedici membri, otto uomini e otto donne, la Repubblica + Pd, Letta: "Nominiamo due donne capigruppo alla Camera e al Senato". Delrio: "Agito sempre per parità", la Repubblica + Pd, Simona Malpezzi è la nuova capogruppo al Senato. E alla Camera vacilla l'ipotesi Serracchiani, la Repubblica + Debora Serracchiani capogruppo Pd alla Camera, ANSA + Letta vince a Siena le suppletive, ANSA + Risultati ballottaggi del 17 e 18 ottobre. A Roma e Torino trionfa il centrosinistra. A Trieste vince il centrodestra, la Repubblica + Quirinale, la proposta di Letta: "Draghi o Mattarella, il bis sarebbe il massimo", la Repubblica + "L'assist di Letta la Mattarella-bis". Corriere della Sera (in Italian). 29 January 2022. Retrieved 29 January 2022. + "Mattarella to be re-elected after saying he is 'willing'". ANSA. 29 January 2022. Archived from the original on 29 January 2022. Retrieved 30 January 2022. + "Elezioni Presidente della repubblica 2022". La Repubblica (in Italian). 29 January 2022. Archived from the original on 29 January 2022. Retrieved 28 January 2022. + Letta: "Evitiamo il colpo di pistola di Sarajevo, se cade il governo si va al voto", Huffington Post + "Italy's government on the brink as 5-Star threatens to boycott confidence vote". Guardian. 13 July 2022. Retrieved 13 July 2022. + Italian Prime Minister Mario Draghi says he’ll resign, government faces collapse, Washington Post + Mattarella respinge dimissioni Draghi e manda premier a Camere, ANSA + Governo in bilico, Letta: "La crisi si apre in Parlamento". Anche la Lega valuta la verifica di maggioranza: cosa significa, la Repubblica + Horowitz, Jason (20 July 2022). "Draghi Government Falls Apart, Returning Turbulent Politics to Italy". The New York Times. ISSN 0362-4331. Archived from the original on 21 July 2022. Retrieved 21 July 2022. + "Italy in limbo as Draghi wins confidence vote but loses parliamentary majority". France 24. Agence-France Press. 20 July 2022. Archived from the original on 20 July 2022. Retrieved 21 July 2022. + Borghese, Livia; Braithwaite, Sharon; Fox, Kara; Latza Nadeau, Barbie; Ruotolo, Nicola (21 July 2022). "Italy's president dissolves parliament, triggering snap election following Draghi's resignation". CNN. Archived from the original on 21 July 2022. Retrieved 22 July 2022. + "«Letta si dimette sotto questa percentuale». E i big già lo archiviano: è caccia al nuovo segretario". 6 September 2022. + "Come una mucca nel corridoio. Il congresso Pd si piazza sul palco di Letta". 23 September 2022. + "Letta pensa alle elezioni, ma il Pd pensa al congresso". + "Pd: Letta, giovedì 6 ottobre direzione sul congresso". 28 September 2022. + Nova, Redazione Agenzia (26 February 2023). "Elly Schlein è la nuova segretaria del Partito democratico". Agenzia Nova (in Italian). Retrieved 26 February 2023. + "Enrico Letta Profile: Mild-Mannered AC Milan Fan who is Italy's Next PM". International Business Times. 24 April 2013. Retrieved 30 April 2013. + Kington, Tom (24 April 2013). "Enrico Letta to become youngest Italian prime minister in 25 years". The Daily Telegraph. Retrieved 4 May 2013. + Tra la passione per la politica, l'Ue e il Milan, chi è Enrico Letta, AGI – Agenzia Italiana +Notes + + It is not altogether clear whether the Doctorate degree was obtained in international law in 1997 as reported in his curriculum vitae,[22] or in political science in 1999 as reported by ANSA.[24] +External links + +Wikimedia Commons has media related to Enrico Letta. +Personal profile of Enrico Letta in the European Parliament's database of members +Declaration (PDF) of financial interests (in Italian) +Political offices +Preceded by +Lamberto Dini +Minister for the Community Policies +1998–1999 Succeeded by +Patrizia Toia +Preceded by +Pier Luigi Bersani +Minister of Industry, Commerce and Crafts +1999–2001 Succeeded by +Antonio Marzano +as Minister of Productive Activities +Preceded by +Gianni Letta +Secretary of the Council of Ministers +2006–2008 Succeeded by +Gianni Letta +Preceded by +Mario Monti +Prime Minister of Italy +2013–2014 Succeeded by +Matteo Renzi +Party political offices +Preceded by +Dario Franceschini +Deputy Secretary of the Democratic Party +2009–2013 Succeeded by +Debora Serracchiani +Succeeded by +Lorenzo Guerini +Preceded by +Nicola Zingaretti +Secretary of the Democratic Party +2021–2023 Succeeded by +Elly Schlein +Enrico Letta +Authority control databases Edit this at Wikidata +Categories: 1966 birthsLiving peoplePeople from PisaItalian Roman CatholicsChristian Democracy (Italy) politiciansItalian People's Party (1994) politiciansDemocracy is Freedom – The Daisy politiciansPrime ministers of ItalyGovernment ministers of ItalyMinisters of agriculture of ItalyDeputies of Legislature XIV of ItalyDeputies of Legislature XV of ItalyDeputies of Legislature XVI of ItalyDeputies of Legislature XVII of ItalyLetta CabinetDemocratic Party (Italy) MEPsMEPs for Italy 2004–2009University of Pisa alumniSant'Anna School of Advanced Studies alumniLeaders of political parties in Italy \ No newline at end of file diff --git a/tests/data/test.json b/tests/data/test.json new file mode 100644 index 00000000..eacfbf5e --- /dev/null +++ b/tests/data/test.json @@ -0,0 +1,22 @@ +{ + "glossary": { + "title": "example glossary", + "GlossDiv": { + "title": "S", + "GlossList": { + "GlossEntry": { + "ID": "SGML", + "SortAs": "SGML", + "GlossTerm": "Standard Generalized Markup Language", + "Acronym": "SGML", + "Abbrev": "ISO 8879:1986", + "GlossDef": { + "para": "A meta-markup language, used to create markup languages such as DocBook.", + "GlossSeeAlso": ["GML", "XML"] + }, + "GlossSee": "markup" + } + } + } + } +} diff --git a/tests/data/test.md b/tests/data/test.md new file mode 100644 index 00000000..365cad8a --- /dev/null +++ b/tests/data/test.md @@ -0,0 +1,245 @@ +--- +__Advertisement :)__ + +- __[pica](https://nodeca.github.io/pica/demo/)__ - high quality and fast image + resize in browser. +- __[babelfish](https://github.com/nodeca/babelfish/)__ - developer friendly + i18n with plurals support and easy syntax. + +You will like those projects! + +--- + +# h1 Heading 8-) +## h2 Heading +### h3 Heading +#### h4 Heading +##### h5 Heading +###### h6 Heading + + +## Horizontal Rules + +___ + +--- + +*** + + +## Typographic replacements + +Enable typographer option to see result. + +(c) (C) (r) (R) (tm) (TM) (p) (P) +- + +test.. test... test..... test?..... test!.... + +!!!!!! ???? ,, -- --- + +"Smartypants, double quotes" and 'single quotes' + + +## Emphasis + +**This is bold text** + +__This is bold text__ + +*This is italic text* + +_This is italic text_ + +~~Strikethrough~~ + + +## Blockquotes + + +> Blockquotes can also be nested... +>> ...by using additional greater-than signs right next to each other... +> > > ...or with spaces between arrows. + + +## Lists + +Unordered + ++ Create a list by starting a line with `+`, `-`, or `*` ++ Sub-lists are made by indenting 2 spaces: + - Marker character change forces new list start: + * Ac tristique libero volutpat at + + Facilisis in pretium nisl aliquet + - Nulla volutpat aliquam velit ++ Very easy! + +Ordered + +1. Lorem ipsum dolor sit amet +2. Consectetur adipiscing elit +3. Integer molestie lorem at massa + + +1. You can use sequential numbers... +1. ...or keep all the numbers as `1.` + +Start numbering with offset: + +57. foo +1. bar + + +## Code + +Inline `code` + +Indented code + + // Some comments + line 1 of code + line 2 of code + line 3 of code + + +Block code "fences" + +``` +Sample text here... +``` + +Syntax highlighting + +``` js +var foo = function (bar) { + return bar++; +}; + +console.log(foo(5)); +``` + +## Tables + +| Option | Description | +| ------ | ----------- | +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + +Right aligned columns + +| Option | Description | +| ------:| -----------:| +| data | path to data files to supply the data that will be passed into templates. | +| engine | engine to be used for processing templates. Handlebars is the default. | +| ext | extension to be used for dest files. | + + +## Links + +[link text](http://dev.nodeca.com) + +[link with title](http://nodeca.github.io/pica/demo/ "title text!") + +Autoconverted link https://github.com/nodeca/pica (enable linkify to see) + + +## Images + +![Minion](https://octodex.github.com/images/minion.png) +![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") + +Like links, Images also have a footnote style syntax + +![Alt text][id] + +With a reference later in the document defining the URL location: + +[id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" + + +## Plugins + +The killer feature of `markdown-it` is very effective support of +[syntax plugins](https://www.npmjs.org/browse/keyword/markdown-it-plugin). + + +### [Emojies](https://github.com/markdown-it/markdown-it-emoji) + +> Classic markup: :wink: :cry: :laughing: :yum: +> +> Shortcuts (emoticons): :-) :-( 8-) ;) + +see [how to change output](https://github.com/markdown-it/markdown-it-emoji#change-output) with twemoji. + + +### [Subscript](https://github.com/markdown-it/markdown-it-sub) / [Superscript](https://github.com/markdown-it/markdown-it-sup) + +- 19^th^ +- H~2~O + + +### [\](https://github.com/markdown-it/markdown-it-ins) + +++Inserted text++ + + +### [\](https://github.com/markdown-it/markdown-it-mark) + +==Marked text== + + +### [Footnotes](https://github.com/markdown-it/markdown-it-footnote) + +Footnote 1 link[^first]. + +Footnote 2 link[^second]. + +Inline footnote^[Text of inline footnote] definition. + +Duplicated footnote reference[^second]. + +[^first]: Footnote **can have markup** + + and multiple paragraphs. + +[^second]: Footnote text. + + +### [Definition lists](https://github.com/markdown-it/markdown-it-deflist) + +Term 1 + +: Definition 1 +with lazy continuation. + +Term 2 with *inline markup* + +: Definition 2 + + { some code, part of Definition 2 } + + Third paragraph of definition 2. + +_Compact style:_ + +Term 1 + ~ Definition 1 + +Term 2 + ~ Definition 2a + ~ Definition 2b + + +### [Abbreviations](https://github.com/markdown-it/markdown-it-abbr) + +This is HTML abbreviation example. + +It converts "HTML", but keep intact partial entries like "xxxHTMLyyy" and so on. + +*[HTML]: Hyper Text Markup Language + +### [Custom containers](https://github.com/markdown-it/markdown-it-container) + +::: warning +*here be dragons* +::: diff --git a/tests/data/toy_chat_fine_tuning.jsonl b/tests/data/toy_chat_fine_tuning.jsonl new file mode 100644 index 00000000..27d92094 --- /dev/null +++ b/tests/data/toy_chat_fine_tuning.jsonl @@ -0,0 +1,5 @@ +{"messages": [{"role": "system", "content": "You are a happy assistant that puts a positive spin on everything."}, {"role": "user", "content": "I fell off my bike today."}, {"role": "assistant", "content": "It's great that you're getting exercise outdoors!"}]} +{"messages": [{"role": "system", "content": "You are a happy assistant that puts a positive spin on everything."}, {"role": "user", "content": "I lost my tennis match today."}, {"role": "assistant", "content": "It's ok, it happens to everyone."}, {"role": "user", "content": "But I trained so hard!"}, {"role": "assistant", "content": "It will pay off next time."}, {"role": "user", "content": "I'm going to switch to golf."}, {"role": "assistant", "content": "Golf is fun too!"}, {"role": "user", "content": "I don't even know how to play golf."}, {"role": "assistant", "content": "It's easy to learn!"}]} +{"messages": [{"role": "user", "content": "I lost my book today."}, {"role": "assistant", "content": "You can read everything on ebooks these days!"}]} +{"messages": [{"role": "system", "content": "You are a happy assistant that puts a positive spin on everything."}, {"role": "assistant", "content": "You're great!"}]} +{"messages": [{"role": "system", "content": "You are a happy assistant that puts a positive spin on everything."}, {"role": "user", "content": "I'm hungry."}, {"role": "assistant", "content": "Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!Eat a banana!"}]} diff --git a/tests/helpers/plugins_helper.py b/tests/helpers/plugins_helper.py new file mode 100644 index 00000000..086f56c5 --- /dev/null +++ b/tests/helpers/plugins_helper.py @@ -0,0 +1,20 @@ +from letta.data_sources.redis_client import get_redis_client +from letta.services.agent_manager import AgentManager + + +async def is_experimental_okay(feature_name: str, **kwargs) -> bool: + print(feature_name, kwargs) + if feature_name == "test_pass_with_kwarg": + return isinstance(kwargs["agent_manager"], AgentManager) + if feature_name == "test_just_pass": + return True + if feature_name == "test_fail": + return False + if feature_name == "test_override_kwarg": + return kwargs["bool_val"] + if feature_name == "test_redis_flag": + client = await get_redis_client() + user_id = kwargs["user_id"] + return await client.check_inclusion_and_exclusion(member=user_id, group="TEST_GROUP") + # Err on safety here, disabling experimental if not handled here. + return False diff --git a/tests/helpers/utils.py b/tests/helpers/utils.py index 2dfa5cca..2a7ec229 100644 --- a/tests/helpers/utils.py +++ b/tests/helpers/utils.py @@ -164,7 +164,9 @@ def comprehensive_agent_checks(agent: AgentState, request: Union[CreateAgent, Up assert agent.message_buffer_autoclear == request.message_buffer_autoclear -def validate_context_window_overview(overview: ContextWindowOverview, attached_file: Optional[FileAgent] = None) -> None: +def validate_context_window_overview( + agent_state: AgentState, overview: ContextWindowOverview, attached_file: Optional[FileAgent] = None +) -> None: """Validate common sense assertions for ContextWindowOverview""" # 1. Current context size should not exceed maximum @@ -238,3 +240,7 @@ def validate_context_window_overview(overview: ContextWindowOverview, attached_f assert attached_file.visible_content in overview.core_memory assert '' in overview.core_memory assert "" in overview.core_memory + + # Check for tools + assert overview.num_tokens_functions_definitions > 0 + assert len(overview.functions_definitions) > 0 diff --git a/tests/integration_test_builtin_tools.py b/tests/integration_test_builtin_tools.py index dfbad8e4..ebb09d03 100644 --- a/tests/integration_test_builtin_tools.py +++ b/tests/integration_test_builtin_tools.py @@ -13,7 +13,6 @@ from letta_client.types import ToolReturnMessage from letta.schemas.agent import AgentState from letta.schemas.llm_config import LLMConfig -from letta.settings import settings # ------------------------------ # Fixtures @@ -54,10 +53,7 @@ def server_url() -> str: else: raise RuntimeError(f"Could not reach {url} within {timeout_seconds}s") - temp = settings.use_experimental - settings.use_experimental = True yield url - settings.use_experimental = temp @pytest.fixture(scope="module") diff --git a/tests/integration_test_multi_agent.py b/tests/integration_test_multi_agent.py index 748f824b..6e9cbf8c 100644 --- a/tests/integration_test_multi_agent.py +++ b/tests/integration_test_multi_agent.py @@ -14,7 +14,6 @@ from letta.schemas.letta_message import SystemMessage, ToolReturnMessage from letta.schemas.tool import Tool from letta.server.server import SyncServer from letta.services.agent_manager import AgentManager -from letta.settings import settings from tests.helpers.utils import retry_until_success from tests.utils import wait_for_incoming_message @@ -53,10 +52,7 @@ def server_url() -> str: else: raise RuntimeError(f"Could not reach {url} within {timeout_seconds}s") - temp = settings.use_experimental - settings.use_experimental = True yield url - settings.use_experimental = temp @pytest.fixture(scope="module") diff --git a/tests/integration_test_pinecone_tool.py b/tests/integration_test_pinecone_tool.py new file mode 100644 index 00000000..9ce7589d --- /dev/null +++ b/tests/integration_test_pinecone_tool.py @@ -0,0 +1,211 @@ +import asyncio +import json +import os +import threading +import time + +import pytest +import requests +from dotenv import load_dotenv +from letta_client import AsyncLetta, MessageCreate, ReasoningMessage, ToolCallMessage +from letta_client.core import RequestOptions + +REASONING_THROTTLE_MS = 100 +TEST_USER_MESSAGE = "What products or services does 11x AI sell?" + + +@pytest.fixture(scope="module") +def server_url() -> str: + """ + Provides the URL for the Letta server. + If LETTA_SERVER_URL is not set, starts the server in a background thread + and polls until it’s accepting connections. + """ + + def _run_server() -> None: + load_dotenv() + from letta.server.rest_api.app import start_server + + start_server(debug=True) + + url: str = os.getenv("LETTA_SERVER_URL", "http://localhost:8283") + + if not os.getenv("LETTA_SERVER_URL"): + thread = threading.Thread(target=_run_server, daemon=True) + thread.start() + + # Poll until the server is up (or timeout) + timeout_seconds = 30 + deadline = time.time() + timeout_seconds + while time.time() < deadline: + try: + resp = requests.get(url + "/v1/health") + if resp.status_code < 500: + break + except requests.exceptions.RequestException: + pass + time.sleep(0.1) + else: + raise RuntimeError(f"Could not reach {url} within {timeout_seconds}s") + + return url + + +@pytest.fixture(scope="function") +def client(server_url: str): + """ + Creates and returns an asynchronous Letta REST client for testing. + """ + async_client_instance = AsyncLetta(base_url=server_url) + yield async_client_instance + + +async def test_pinecone_tool(client: AsyncLetta) -> None: + """ + Test the Pinecone tool integration with the Letta client. + """ + with open("../../scripts/test-afs/knowledge-base.af", "rb") as f: + agent = await client.agents.import_file(file=f) + + agent = await client.agents.modify( + agent_id=agent.id, + tool_exec_environment_variables={ + "PINECONE_INDEX_HOST": os.getenv("PINECONE_INDEX_HOST"), + "PINECONE_API_KEY": os.getenv("PINECONE_API_KEY"), + "PINECONE_NAMESPACE": os.getenv("PINECONE_NAMESPACE"), + }, + ) + last_message = await client.agents.messages.list( + agent_id=agent.id, + limit=1, + ) + + curr_message_type = None + messages = [] + reasoning_content = [] + last_reasoning_update_ms = 0 + tool_call_content = "" + tool_return_content = "" + summary = None + pinecone_results = None + queries = [] + + try: + response = client.agents.messages.create_stream( + agent_id=agent.id, + messages=[ + MessageCreate( + role="user", + content=TEST_USER_MESSAGE, + ), + ], + stream_tokens=True, + request_options=RequestOptions( + timeout_in_seconds=1000, + ), + ) + + async for chunk in response: + if chunk.message_type != curr_message_type: + messages.append(chunk) + curr_message_type = chunk.message_type + if curr_message_type == "reasoning_message": + reasoning_content = [] + if curr_message_type == "tool_call_message": + tool_call_content = "" + + if chunk.message_type == "reasoning_message": + now_ms = time.time_ns() // 1_000_000 + if now_ms - last_reasoning_update_ms < REASONING_THROTTLE_MS: + await asyncio.sleep(REASONING_THROTTLE_MS / 1000) + + last_reasoning_update_ms = now_ms + if len(reasoning_content) == 0: + reasoning_content = [chunk.reasoning] + else: + reasoning_content[-1] += chunk.reasoning + + message_dict = messages[-1].model_dump() + message_dict["reasoning"] = "".join(reasoning_content).strip() + messages[-1] = ReasoningMessage(**message_dict) + + if chunk.message_type == "tool_return_message": + tool_return_content += chunk.tool_return + + if chunk.status == "success": + try: + if chunk.name == "summarize_pinecone_results": + json_response = json.loads(chunk.tool_return) + summary = json_response.get("summary", None) + pinecone_results = json_response.get("pinecone_results", None) + tool_return_content = "" + elif chunk.name == "craft_queries": + queries.append(chunk.tool_return) + tool_return_content = "" + except Exception as e: + print(f"Error parsing JSON response: {str(e)}. {chunk.tool_return}\n") + tool_return_content = "" + + if chunk.message_type == "tool_call_message": + if chunk.tool_call.arguments is not None: + tool_call_content += chunk.tool_call.arguments + message_dict = messages[-1].model_dump() + message_dict["tool_call"]["arguments"] = tool_call_content + messages[-1] = ToolCallMessage(**message_dict) + + except Exception as e: + print(f"Failed to fetch knowledge base response: {str(e)}\n") + print(tool_call_content) + raise e + + assert len(messages) > 0, "No messages received from the agent." + assert len(reasoning_content) > 0, "No reasoning content received from the agent." + assert summary is not None, "No summary received from the agent." + assert pinecone_results is not None, "No Pinecone results received from the agent." + assert len(queries) > 0, "No queries received from the agent." + + assert messages[-1].message_type == "usage_statistics", "Last message in stream must be usage stats." + response_messages_from_stream = [m for m in messages if m.message_type != "usage_statistics"] + response_message_types_from_stream = [m.message_type for m in response_messages_from_stream] + + messages_from_db = await client.agents.messages.list( + agent_id=agent.id, + after=last_message[0].id, + limit=100, + ) + response_messages_from_db = [m for m in messages_from_db if m.message_type != "user_message"] + response_message_types_from_db = [m.message_type for m in response_messages_from_db] + + assert len(response_messages_from_stream) == len(response_messages_from_db) + assert response_message_types_from_stream == response_message_types_from_db + for idx in range(len(response_messages_from_stream)): + stream_message = response_messages_from_stream[idx] + db_message = response_messages_from_db[idx] + assert stream_message.message_type == db_message.message_type + assert stream_message.id == db_message.id + assert stream_message.otid == db_message.otid + + if stream_message.message_type == "reasoning_message": + assert stream_message.reasoning == db_message.reasoning + + if stream_message.message_type == "tool_call_message": + assert stream_message.tool_call.tool_call_id == db_message.tool_call.tool_call_id + assert stream_message.tool_call.name == db_message.tool_call.name + + if stream_message.tool_call.name == "craft_queries": + assert "queries" in stream_message.tool_call.arguments + assert "queries" in db_message.tool_call.arguments + if stream_message.tool_call.name == "search_and_store_pinecone_records": + assert "query_text" in stream_message.tool_call.arguments + assert "query_text" in db_message.tool_call.arguments + if stream_message.tool_call.name == "summarize_pinecone_results": + assert "summary" in stream_message.tool_call.arguments + assert "summary" in db_message.tool_call.arguments + + assert "inner_thoughts" not in stream_message.tool_call.arguments + assert "inner_thoughts" not in db_message.tool_call.arguments + + if stream_message.message_type == "tool_return_message": + assert stream_message.tool_return == db_message.tool_return + + await client.agents.delete(agent_id=agent.id) diff --git a/tests/integration_test_sleeptime_agent.py b/tests/integration_test_sleeptime_agent.py index 2d5f9bf2..14b288e4 100644 --- a/tests/integration_test_sleeptime_agent.py +++ b/tests/integration_test_sleeptime_agent.py @@ -89,7 +89,7 @@ async def test_sleeptime_group_chat(server, actor): assert "archival_memory_insert" not in main_agent_tools # 2. Override frequency for test - group = server.group_manager.modify_group( + group = await server.group_manager.modify_group_async( group_id=main_agent.multi_agent_group.id, group_update=GroupUpdate( manager_config=SleeptimeManagerUpdate( @@ -204,7 +204,7 @@ async def test_sleeptime_group_chat_v2(server, actor): assert "archival_memory_insert" not in main_agent_tools # 2. Override frequency for test - group = server.group_manager.modify_group( + group = await server.group_manager.modify_group_async( group_id=main_agent.multi_agent_group.id, group_update=GroupUpdate( manager_config=SleeptimeManagerUpdate( @@ -317,7 +317,7 @@ async def test_sleeptime_removes_redundant_information(server, actor): actor=actor, ) - group = server.group_manager.modify_group( + group = await server.group_manager.modify_group_async( group_id=main_agent.multi_agent_group.id, group_update=GroupUpdate( manager_config=SleeptimeManagerUpdate( diff --git a/tests/integration_test_voice_agent.py b/tests/integration_test_voice_agent.py index b3fc86dc..7c9e3e9e 100644 --- a/tests/integration_test_voice_agent.py +++ b/tests/integration_test_voice_agent.py @@ -343,7 +343,7 @@ async def test_voice_recall_memory(disable_e2b_api_key, voice_agent, message, en @pytest.mark.asyncio(loop_scope="module") @pytest.mark.parametrize("endpoint", ["v1/voice-beta"]) async def test_trigger_summarization(disable_e2b_api_key, server, voice_agent, group_id, endpoint, actor, server_url): - server.group_manager.modify_group( + await server.group_manager.modify_group_async( group_id=group_id, group_update=GroupUpdate( manager_config=VoiceSleeptimeManagerUpdate( @@ -572,9 +572,9 @@ async def test_init_voice_convo_agent(voice_agent, server, actor, server_url): server.agent_manager.get_agent_by_id(agent_id=sleeptime_agent_id, actor=actor) -def _modify(group_id, server, actor, max_val, min_val): +async def _modify(group_id, server, actor, max_val, min_val): """Helper to invoke modify_group with voice_sleeptime config.""" - return server.group_manager.modify_group( + return await server.group_manager.modify_group_async( group_id=group_id, group_update=GroupUpdate( manager_config=VoiceSleeptimeManagerUpdate( @@ -587,27 +587,31 @@ def _modify(group_id, server, actor, max_val, min_val): ) -def test_valid_buffer_lengths_above_four(group_id, server, actor): +@pytest.mark.asyncio(loop_scope="module") +async def test_valid_buffer_lengths_above_four(group_id, server, actor): # both > 4 and max > min - updated = _modify(group_id, server, actor, max_val=10, min_val=5) + updated = await _modify(group_id, server, actor, max_val=10, min_val=5) assert updated.max_message_buffer_length == 10 assert updated.min_message_buffer_length == 5 -def test_valid_buffer_lengths_only_max(group_id, server, actor): +@pytest.mark.asyncio(loop_scope="module") +async def test_valid_buffer_lengths_only_max(group_id, server, actor): # both > 4 and max > min - updated = _modify(group_id, server, actor, max_val=DEFAULT_MAX_MESSAGE_BUFFER_LENGTH + 1, min_val=None) + updated = await _modify(group_id, server, actor, max_val=DEFAULT_MAX_MESSAGE_BUFFER_LENGTH + 1, min_val=None) assert updated.max_message_buffer_length == DEFAULT_MAX_MESSAGE_BUFFER_LENGTH + 1 assert updated.min_message_buffer_length == DEFAULT_MIN_MESSAGE_BUFFER_LENGTH -def test_valid_buffer_lengths_only_min(group_id, server, actor): +@pytest.mark.asyncio(loop_scope="module") +async def test_valid_buffer_lengths_only_min(group_id, server, actor): # both > 4 and max > min - updated = _modify(group_id, server, actor, max_val=None, min_val=DEFAULT_MIN_MESSAGE_BUFFER_LENGTH + 1) + updated = await _modify(group_id, server, actor, max_val=None, min_val=DEFAULT_MIN_MESSAGE_BUFFER_LENGTH + 1) assert updated.max_message_buffer_length == DEFAULT_MAX_MESSAGE_BUFFER_LENGTH assert updated.min_message_buffer_length == DEFAULT_MIN_MESSAGE_BUFFER_LENGTH + 1 +@pytest.mark.asyncio(loop_scope="module") @pytest.mark.parametrize( "max_val,min_val,err_part", [ @@ -624,7 +628,7 @@ def test_valid_buffer_lengths_only_min(group_id, server, actor): (10, 1, "greater than 4"), ], ) -def test_invalid_buffer_lengths(group_id, server, actor, max_val, min_val, err_part): +async def test_invalid_buffer_lengths(group_id, server, actor, max_val, min_val, err_part): with pytest.raises(ValueError) as exc: - _modify(group_id, server, actor, max_val, min_val) + await _modify(group_id, server, actor, max_val, min_val) assert err_part in str(exc.value) diff --git a/tests/mcp/test_mcp.py b/tests/mcp/test_mcp.py index 618e1700..41ee8e8c 100644 --- a/tests/mcp/test_mcp.py +++ b/tests/mcp/test_mcp.py @@ -150,10 +150,9 @@ async def test_sse_mcp_server(client, agent_state): assert tr.status == "success", f"Bad status: {tr.status}" # parse JSON payload full_payload = json.loads(tr.tool_return) - payload = json.loads(full_payload["message"]) - assert payload.get("successful", False), f"Tool returned failure payload: {payload}" - assert payload["data"]["details"] == "Action executed successfully", f"Unexpected details: {payload}" + assert full_payload.get("successful", False), f"Tool returned failure payload: {full_payload}" + assert full_payload["data"]["details"] == "Action executed successfully", f"Unexpected details: {full_payload}" def test_stdio_mcp_server(client, agent_state): diff --git a/tests/test_client.py b/tests/test_client.py index e2e691eb..c957a5b5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -347,48 +347,6 @@ def test_update_agent_memory_limit(client: Letta): # -------------------------------------------------------------------------------------------------------------------- # Agent Tools # -------------------------------------------------------------------------------------------------------------------- -def test_function_return_limit(client: Letta): - """Test to see if the function return limit works""" - - def big_return(): - """ - Always call this tool. - - Returns: - important_data (str): Important data - """ - return "x" * 100000 - - padding = len("[NOTE: function output was truncated since it exceeded the character limit (100000 > 1000)]") + 50 - tool = client.tools.upsert_from_function(func=big_return, return_char_limit=1000) - agent = client.agents.create( - model="letta/letta-free", - embedding="letta/letta-free", - tool_ids=[tool.id], - ) - # get function response - response = client.agents.messages.create( - agent_id=agent.id, messages=[MessageCreate(role="user", content="call the big_return function")] - ) - print(response.messages) - - response_message = None - for message in response.messages: - if message.message_type == "tool_return_message": - response_message = message - break - - assert response_message, "ToolReturnMessage message not found in response" - res = response_message.tool_return - assert "function output was truncated " in res - - # TODO: Re-enable later - # res_json = json.loads(res) - # assert ( - # len(res_json["message"]) <= 1000 + padding - # ), f"Expected length to be less than or equal to 1000 + {padding}, but got {len(res_json['message'])}" - - client.agents.delete(agent_id=agent.id) def test_function_always_error(client: Letta): @@ -495,14 +453,6 @@ def test_messages(client: Letta, agent: AgentState): assert len(messages_response) > 0, "Retrieving messages failed" -def test_send_system_message(client: Letta, agent: AgentState): - """Important unit test since the Letta API exposes sending system messages, but some backends don't natively support it (eg Anthropic)""" - send_system_message_response = client.agents.messages.create( - agent_id=agent.id, messages=[MessageCreate(role="system", content="Event occurred: The user just logged off.")] - ) - assert send_system_message_response, "Sending message failed" - - # TODO: Add back when new agent loop hits # @pytest.mark.asyncio # async def test_send_message_parallel(client: Letta, agent: AgentState, request): diff --git a/tests/test_client_legacy.py b/tests/test_client_legacy.py index f2ebe000..eb223145 100644 --- a/tests/test_client_legacy.py +++ b/tests/test_client_legacy.py @@ -15,7 +15,7 @@ from letta.helpers.datetime_helpers import get_utc_time from letta.orm import FileMetadata, Source from letta.schemas.agent import AgentState from letta.schemas.embedding_config import EmbeddingConfig -from letta.schemas.enums import MessageRole, MessageStreamStatus +from letta.schemas.enums import MessageRole from letta.schemas.letta_message import ( AssistantMessage, LettaMessage, @@ -25,10 +25,8 @@ from letta.schemas.letta_message import ( ToolReturnMessage, UserMessage, ) -from letta.schemas.letta_response import LettaStreamingResponse from letta.schemas.llm_config import LLMConfig from letta.schemas.message import MessageCreate -from letta.schemas.usage import LettaUsageStatistics from letta.services.helpers.agent_manager_helper import initialize_message_sequence from letta.services.organization_manager import OrganizationManager from letta.services.user_manager import UserManager @@ -214,74 +212,6 @@ def test_core_memory(disable_e2b_api_key, client: RESTClient, agent: AgentState) assert "Timber" in memory.get_block("human").value, f"Updating core memory failed: {memory.get_block('human').value}" -@pytest.mark.parametrize( - "stream_tokens,model", - [ - (True, "gpt-4o-mini"), - (True, "claude-3-sonnet-20240229"), - (False, "gpt-4o-mini"), - (False, "claude-3-sonnet-20240229"), - ], -) -def test_streaming_send_message( - disable_e2b_api_key, - client: RESTClient, - agent: AgentState, - stream_tokens: bool, - model: str, -): - # Update agent's model - agent.llm_config.model = model - - # First, try streaming just steps - - # Next, try streaming both steps and tokens - response = client.send_message( - agent_id=agent.id, - message="This is a test. Repeat after me: 'banana'", - role="user", - stream_steps=True, - stream_tokens=stream_tokens, - ) - - # Some manual checks to run - # 1. Check that there were inner thoughts - inner_thoughts_exist = False - inner_thoughts_count = 0 - # 2. Check that the agent runs `send_message` - send_message_ran = False - # 3. Check that we get all the start/stop/end tokens we want - # This includes all of the MessageStreamStatus enums - done = False - - assert response, "Sending message failed" - for chunk in response: - assert isinstance(chunk, LettaStreamingResponse) - if isinstance(chunk, ReasoningMessage) and chunk.reasoning and chunk.reasoning != "": - inner_thoughts_exist = True - inner_thoughts_count += 1 - if isinstance(chunk, ToolCallMessage) and chunk.tool_call and chunk.tool_call.name == "send_message": - send_message_ran = True - if isinstance(chunk, AssistantMessage): - send_message_ran = True - if isinstance(chunk, MessageStreamStatus): - if chunk == MessageStreamStatus.done: - assert not done, "Message stream already done" - done = True - if isinstance(chunk, LettaUsageStatistics): - # Some rough metrics for a reasonable usage pattern - assert chunk.step_count == 1 - assert chunk.completion_tokens > 10 - assert chunk.prompt_tokens > 1000 - assert chunk.total_tokens > 1000 - - # If stream tokens, we expect at least one inner thought - assert inner_thoughts_count >= 1, "Expected more than one inner thought" - assert inner_thoughts_exist, "No inner thoughts found" - assert send_message_ran, "send_message function call not found" - assert done, "Message stream not done" - - def test_humans_personas(client: RESTClient, agent: AgentState): # _reset_config() diff --git a/tests/test_managers.py b/tests/test_managers.py index f8a42d82..f467bdad 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -9,12 +9,15 @@ from datetime import datetime, timedelta, timezone from typing import List import httpx + +# tests/test_file_content_flow.py import pytest from anthropic.types.beta import BetaMessage from anthropic.types.beta.messages import BetaMessageBatchIndividualResponse, BetaMessageBatchSucceededResult from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall from openai.types.chat.chat_completion_message_tool_call import Function as OpenAIFunction -from sqlalchemy.exc import IntegrityError +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError, InvalidRequestError from sqlalchemy.orm.exc import StaleDataError from letta.config import LettaConfig @@ -25,6 +28,7 @@ from letta.constants import ( BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, BUILTIN_TOOLS, + FILES_TOOLS, LETTA_TOOL_EXECUTION_DIR, LETTA_TOOL_SET, MCP_TOOL_TAG_NAME_PREFIX, @@ -39,11 +43,13 @@ from letta.orm import Base, Block from letta.orm.block_history import BlockHistory from letta.orm.enums import ActorType, JobType, ToolType from letta.orm.errors import NoResultFound, UniqueConstraintViolationError +from letta.orm.file import FileContent as FileContentModel +from letta.orm.file import FileMetadata as FileMetadataModel from letta.schemas.agent import AgentStepState, CreateAgent, UpdateAgent from letta.schemas.block import Block as PydanticBlock from letta.schemas.block import BlockUpdate, CreateBlock from letta.schemas.embedding_config import EmbeddingConfig -from letta.schemas.enums import AgentStepStatus, JobStatus, MessageRole, ProviderType +from letta.schemas.enums import AgentStepStatus, FileProcessingStatus, JobStatus, MessageRole, ProviderType from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate, SandboxEnvironmentVariableUpdate from letta.schemas.file import FileMetadata as PydanticFileMetadata from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityPropertyType, IdentityType, IdentityUpdate, IdentityUpsert @@ -93,15 +99,26 @@ CREATE_DELAY_SQLITE = 1 USING_SQLITE = not bool(os.getenv("LETTA_PG_URI")) -@pytest.fixture(autouse=True) -async def _clear_tables(): +async def _count_file_content_rows(session, file_id: str) -> int: + q = select(func.count()).select_from(FileContentModel).where(FileContentModel.file_id == file_id) + result = await session.execute(q) + return result.scalar_one() + + +@pytest.fixture +async def async_session(): async with db_registry.async_session() as session: - for table in reversed(Base.metadata.sorted_tables): # Reverse to avoid FK issues - # If this is the block_history table, skip it - if table.name == "block_history": - continue - await session.execute(table.delete()) # Truncate table - await session.commit() + yield session + + +@pytest.fixture(autouse=True) +async def _clear_tables(async_session): + for table in reversed(Base.metadata.sorted_tables): # Reverse to avoid FK issues + # If this is the block_history table, skip it + if table.name == "block_history": + continue + await async_session.execute(table.delete()) # Truncate table + await async_session.commit() @pytest.fixture @@ -645,6 +662,7 @@ async def file_attachment(server, default_user, sarah_agent, default_file): assoc = await server.file_agent_manager.attach_file( agent_id=sarah_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, visible_content="initial", ) @@ -689,23 +707,37 @@ async def test_create_get_list_agent(server: SyncServer, comprehensive_test_agen assert len(list_agents) == 0 +@pytest.fixture(params=["", "PRODUCTION"]) +def set_letta_environment(request): + original = os.environ.get("LETTA_ENVIRONMENT") + os.environ["LETTA_ENVIRONMENT"] = request.param + yield request.param + # Restore original environment variable + if original is not None: + os.environ["LETTA_ENVIRONMENT"] = original + else: + os.environ.pop("LETTA_ENVIRONMENT", None) + + @pytest.mark.asyncio -async def test_get_context_window_basic(server: SyncServer, comprehensive_test_agent_fixture, default_user, default_file, event_loop): +async def test_get_context_window_basic( + server: SyncServer, comprehensive_test_agent_fixture, default_user, default_file, event_loop, set_letta_environment +): # Test agent creation created_agent, create_agent_request = comprehensive_test_agent_fixture - comprehensive_agent_checks(created_agent, create_agent_request, actor=default_user) # Attach a file assoc = await server.file_agent_manager.attach_file( agent_id=created_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, visible_content="hello", ) # Get context window and check for basic appearances context_window_overview = await server.agent_manager.get_context_window(agent_id=created_agent.id, actor=default_user) - validate_context_window_overview(context_window_overview, assoc) + validate_context_window_overview(created_agent, context_window_overview, assoc) # Test deleting the agent server.agent_manager.delete_agent(created_agent.id, default_user) @@ -713,6 +745,24 @@ async def test_get_context_window_basic(server: SyncServer, comprehensive_test_a assert len(list_agents) == 0 +@pytest.mark.asyncio +async def test_get_context_window_composio_tool( + server: SyncServer, comprehensive_test_agent_fixture, default_user, default_file, event_loop, set_letta_environment +): + # Test agent creation + created_agent, create_agent_request = comprehensive_test_agent_fixture + + # Attach a composio tool + tool_create = ToolCreate.from_composio(action_name="GITHUB_GET_EMOJIS") + tool = server.tool_manager.create_or_update_composio_tool(tool_create=tool_create, actor=default_user) + + created_agent = server.agent_manager.attach_tool(agent_id=created_agent.id, tool_id=tool.id, actor=default_user) + + # Get context window and check for basic appearances + context_window_overview = await server.agent_manager.get_context_window(agent_id=created_agent.id, actor=default_user) + validate_context_window_overview(created_agent, context_window_overview) + + @pytest.mark.asyncio async def test_create_agent_passed_in_initial_messages(server: SyncServer, default_user, default_block, event_loop): memory_blocks = [CreateBlock(label="human", value="BananaBoy"), CreateBlock(label="persona", value="I am a helpful assistant")] @@ -1859,6 +1909,12 @@ async def test_agent_list_passages_basic(server, default_user, sarah_agent, agen all_passages = await server.agent_manager.list_passages_async(actor=default_user, agent_id=sarah_agent.id) assert len(all_passages) == 5 # 3 source + 2 agent passages + source_passages = await server.agent_manager.list_source_passages_async(actor=default_user, agent_id=sarah_agent.id) + assert len(source_passages) == 3 # 3 source + 2 agent passages + + agent_passages = await server.agent_manager.list_agent_passages_async(actor=default_user, agent_id=sarah_agent.id) + assert len(agent_passages) == 2 # 3 source + 2 agent passages + @pytest.mark.asyncio async def test_agent_list_passages_ordering(server, default_user, sarah_agent, agent_passages_setup, event_loop): @@ -2486,12 +2542,53 @@ async def test_upsert_base_tools(server: SyncServer, default_user, event_loop): assert t.tool_type == ToolType.LETTA_VOICE_SLEEPTIME_CORE elif t.name in BUILTIN_TOOLS: assert t.tool_type == ToolType.LETTA_BUILTIN + elif t.name in FILES_TOOLS: + assert t.tool_type == ToolType.LETTA_FILES_CORE else: pytest.fail(f"The tool name is unrecognized as a base tool: {t.name}") assert t.source_code is None assert t.json_schema +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_type,expected_names", + [ + (ToolType.LETTA_CORE, BASE_TOOLS), + (ToolType.LETTA_MEMORY_CORE, BASE_MEMORY_TOOLS), + (ToolType.LETTA_MULTI_AGENT_CORE, MULTI_AGENT_TOOLS), + (ToolType.LETTA_SLEEPTIME_CORE, BASE_SLEEPTIME_TOOLS), + (ToolType.LETTA_VOICE_SLEEPTIME_CORE, sorted(set(BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS) - {"send_message"})), + (ToolType.LETTA_BUILTIN, BUILTIN_TOOLS), + (ToolType.LETTA_FILES_CORE, FILES_TOOLS), + ], +) +async def test_upsert_filtered_base_tools(server: SyncServer, default_user, tool_type, expected_names): + tools = await server.tool_manager.upsert_base_tools_async(actor=default_user, allowed_types={tool_type}) + tool_names = sorted([t.name for t in tools]) + expected_sorted = sorted(expected_names) + + assert tool_names == expected_sorted + assert all(t.tool_type == tool_type for t in tools) + + +@pytest.mark.asyncio +async def test_upsert_multiple_tool_types(server: SyncServer, default_user): + allowed = {ToolType.LETTA_CORE, ToolType.LETTA_BUILTIN, ToolType.LETTA_FILES_CORE} + tools = await server.tool_manager.upsert_base_tools_async(actor=default_user, allowed_types=allowed) + tool_names = {t.name for t in tools} + expected = set(BASE_TOOLS + BUILTIN_TOOLS + FILES_TOOLS) + + assert tool_names == expected + assert all(t.tool_type in allowed for t in tools) + + +@pytest.mark.asyncio +async def test_upsert_base_tools_with_empty_type_filter(server: SyncServer, default_user): + tools = await server.tool_manager.upsert_base_tools_async(actor=default_user, allowed_types=set()) + assert tools == [] + + # ====================================================================================================================== # Message Manager Tests # ====================================================================================================================== @@ -4077,6 +4174,103 @@ async def test_get_file_by_id(server: SyncServer, default_user, default_source): assert retrieved_file.file_type == created_file.file_type +@pytest.mark.asyncio +async def test_create_and_retrieve_file_with_content(server, default_user, default_source, async_session): + text_body = "Line 1\nLine 2\nLine 3" + + meta = PydanticFileMetadata( + file_name="with_body.txt", + file_path="/tmp/with_body.txt", + file_type="text/plain", + file_size=len(text_body), + source_id=default_source.id, + ) + + created = await server.source_manager.create_file( + file_metadata=meta, + actor=default_user, + text=text_body, + ) + + # -- metadata-only return: content is NOT present + assert created.content is None + + # body row exists + assert await _count_file_content_rows(async_session, created.id) == 1 + + # -- now fetch WITH the body + loaded = await server.source_manager.get_file_by_id(created.id, actor=default_user, include_content=True) + assert loaded.content == text_body + + +@pytest.mark.asyncio +async def test_create_file_without_content(server, default_user, default_source, async_session): + meta = PydanticFileMetadata( + file_name="no_body.txt", + file_path="/tmp/no_body.txt", + file_type="text/plain", + file_size=123, + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user) + + # no content row + assert await _count_file_content_rows(async_session, created.id) == 0 + + # include_content=True still works, returns None + loaded = await server.source_manager.get_file_by_id(created.id, actor=default_user, include_content=True) + assert loaded.content is None + + +@pytest.mark.asyncio +async def test_lazy_raise_guard(server, default_user, default_source, async_session): + text_body = "lazy-raise" + + meta = PydanticFileMetadata( + file_name="lazy_raise.txt", + file_path="/tmp/lazy_raise.txt", + file_type="text/plain", + file_size=len(text_body), + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user, text=text_body) + + # Grab ORM instance WITHOUT selectinload(FileMetadata.content) + orm = await async_session.get(FileMetadataModel, created.id) + + # to_pydantic(include_content=True) should raise – guard works + with pytest.raises(InvalidRequestError): + await orm.to_pydantic_async(include_content=True) + + +@pytest.mark.asyncio +async def test_list_files_content_none(server, default_user, default_source): + files = await server.source_manager.list_files(source_id=default_source.id, actor=default_user) + assert all(f.content is None for f in files) + + +@pytest.mark.asyncio +async def test_delete_cascades_to_content(server, default_user, default_source, async_session): + text_body = "to be deleted" + meta = PydanticFileMetadata( + file_name="delete_me.txt", + file_path="/tmp/delete_me.txt", + file_type="text/plain", + file_size=len(text_body), + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user, text=text_body) + + # ensure row exists first + assert await _count_file_content_rows(async_session, created.id) == 1 + + # delete + await server.source_manager.delete_file(created.id, actor=default_user) + + # content row gone + assert await _count_file_content_rows(async_session, created.id) == 0 + + @pytest.mark.asyncio async def test_list_files(server: SyncServer, default_user, default_source): """Test listing files with pagination.""" @@ -4127,6 +4321,105 @@ async def test_delete_file(server: SyncServer, default_user, default_source): assert len(files) == 0 +@pytest.mark.asyncio +async def test_update_file_status_basic(server, default_user, default_source): + """Update processing status and error message for a file.""" + meta = PydanticFileMetadata( + file_name="status_test.txt", + file_path="/tmp/status_test.txt", + file_type="text/plain", + file_size=100, + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user) + + # Update status only + updated = await server.source_manager.update_file_status( + file_id=created.id, + actor=default_user, + processing_status=FileProcessingStatus.PARSING, + ) + assert updated.processing_status == FileProcessingStatus.PARSING + assert updated.error_message is None + + # Update both status and error message + updated = await server.source_manager.update_file_status( + file_id=created.id, + actor=default_user, + processing_status=FileProcessingStatus.ERROR, + error_message="Parse failed", + ) + assert updated.processing_status == FileProcessingStatus.ERROR + assert updated.error_message == "Parse failed" + + +@pytest.mark.asyncio +async def test_update_file_status_error_only(server, default_user, default_source): + """Update just the error message, leave status unchanged.""" + meta = PydanticFileMetadata( + file_name="error_only.txt", + file_path="/tmp/error_only.txt", + file_type="text/plain", + file_size=123, + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user) + + updated = await server.source_manager.update_file_status( + file_id=created.id, + actor=default_user, + error_message="Timeout while embedding", + ) + assert updated.error_message == "Timeout while embedding" + assert updated.processing_status == FileProcessingStatus.PENDING # default from creation + + +@pytest.mark.asyncio +async def test_upsert_file_content_basic(server: SyncServer, default_user, default_source, async_session): + """Test creating and updating file content with upsert_file_content().""" + initial_text = "Initial content" + updated_text = "Updated content" + + # Step 1: Create file with no content + meta = PydanticFileMetadata( + file_name="upsert_body.txt", + file_path="/tmp/upsert_body.txt", + file_type="text/plain", + file_size=len(initial_text), + source_id=default_source.id, + ) + created = await server.source_manager.create_file(file_metadata=meta, actor=default_user) + assert created.content is None + + # Step 2: Insert new content + file_with_content = await server.source_manager.upsert_file_content( + file_id=created.id, + text=initial_text, + actor=default_user, + ) + assert file_with_content.content == initial_text + + # Verify body row exists + count = await _count_file_content_rows(async_session, created.id) + assert count == 1 + + # Step 3: Update existing content + file_with_updated_content = await server.source_manager.upsert_file_content( + file_id=created.id, + text=updated_text, + actor=default_user, + ) + assert file_with_updated_content.content == updated_text + + # Ensure still only 1 row in content table + count = await _count_file_content_rows(async_session, created.id) + assert count == 1 + + # Ensure `updated_at` is bumped + orm_file = await async_session.get(FileMetadataModel, created.id) + assert orm_file.updated_at > orm_file.created_at + + # ====================================================================================================================== # SandboxConfigManager Tests - Sandbox Configs # ====================================================================================================================== @@ -5811,6 +6104,7 @@ async def test_attach_creates_association(server, default_user, sarah_agent, def assoc = await server.file_agent_manager.attach_file( agent_id=sarah_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, visible_content="hello", ) @@ -5832,6 +6126,7 @@ async def test_attach_is_idempotent(server, default_user, sarah_agent, default_f a1 = await server.file_agent_manager.attach_file( agent_id=sarah_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, visible_content="first", ) @@ -5840,6 +6135,7 @@ async def test_attach_is_idempotent(server, default_user, sarah_agent, default_f a2 = await server.file_agent_manager.attach_file( agent_id=sarah_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, is_open=False, visible_content="second", @@ -5858,7 +6154,7 @@ async def test_attach_is_idempotent(server, default_user, sarah_agent, default_f @pytest.mark.asyncio async def test_update_file_agent(server, file_attachment, default_user): - updated = await server.file_agent_manager.update_file_agent( + updated = await server.file_agent_manager.update_file_agent_by_id( agent_id=file_attachment.agent_id, file_id=file_attachment.file_id, actor=default_user, @@ -5869,6 +6165,19 @@ async def test_update_file_agent(server, file_attachment, default_user): assert updated.visible_content == "updated" +@pytest.mark.asyncio +async def test_update_file_agent_by_file_name(server, file_attachment, default_user): + updated = await server.file_agent_manager.update_file_agent_by_name( + agent_id=file_attachment.agent_id, + file_name=file_attachment.file_name, + actor=default_user, + is_open=False, + visible_content="updated", + ) + assert updated.is_open is False + assert updated.visible_content == "updated" + + @pytest.mark.asyncio async def test_mark_access(server, file_attachment, default_user): old_ts = file_attachment.last_accessed_at @@ -5882,7 +6191,7 @@ async def test_mark_access(server, file_attachment, default_user): file_id=file_attachment.file_id, actor=default_user, ) - refreshed = await server.file_agent_manager.get_file_agent( + refreshed = await server.file_agent_manager.get_file_agent_by_id( agent_id=file_attachment.agent_id, file_id=file_attachment.file_id, actor=default_user, @@ -5900,11 +6209,17 @@ async def test_list_files_and_agents( another_file, ): # default_file ↔ charles (open) - await server.file_agent_manager.attach_file(agent_id=charles_agent.id, file_id=default_file.id, actor=default_user) + await server.file_agent_manager.attach_file( + agent_id=charles_agent.id, file_id=default_file.id, file_name=default_file.file_name, actor=default_user + ) # default_file ↔ sarah (open) - await server.file_agent_manager.attach_file(agent_id=sarah_agent.id, file_id=default_file.id, actor=default_user) + await server.file_agent_manager.attach_file( + agent_id=sarah_agent.id, file_id=default_file.id, file_name=default_file.file_name, actor=default_user + ) # another_file ↔ sarah (closed) - await server.file_agent_manager.attach_file(agent_id=sarah_agent.id, file_id=another_file.id, actor=default_user, is_open=False) + await server.file_agent_manager.attach_file( + agent_id=sarah_agent.id, file_id=another_file.id, file_name=another_file.file_name, actor=default_user, is_open=False + ) files_for_sarah = await server.file_agent_manager.list_files_for_agent(sarah_agent.id, actor=default_user) assert {f.file_id for f in files_for_sarah} == {default_file.id, another_file.id} @@ -5932,7 +6247,7 @@ async def test_detach_file(server, file_attachment, default_user): file_id=file_attachment.file_id, actor=default_user, ) - res = await server.file_agent_manager.get_file_agent( + res = await server.file_agent_manager.get_file_agent_by_id( agent_id=file_attachment.agent_id, file_id=file_attachment.file_id, actor=default_user, @@ -5952,6 +6267,7 @@ async def test_org_scoping( await server.file_agent_manager.attach_file( agent_id=sarah_agent.id, file_id=default_file.id, + file_name=default_file.file_name, actor=default_user, ) diff --git a/tests/test_multi_agent.py b/tests/test_multi_agent.py index 8389a167..e118f6a3 100644 --- a/tests/test_multi_agent.py +++ b/tests/test_multi_agent.py @@ -147,7 +147,7 @@ def manager_agent(server, actor): server.agent_manager.delete_agent(agent_scooby.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_empty_group(server, actor): group = server.group_manager.create_group( group=GroupCreate( @@ -172,7 +172,7 @@ async def test_empty_group(server, actor): server.group_manager.delete_group(group_id=group.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_modify_group_pattern(server, actor, participant_agents, manager_agent): group = server.group_manager.create_group( group=GroupCreate( @@ -182,7 +182,7 @@ async def test_modify_group_pattern(server, actor, participant_agents, manager_a actor=actor, ) with pytest.raises(ValueError, match="Cannot change group pattern"): - server.group_manager.modify_group( + await server.group_manager.modify_group_async( group_id=group.id, group_update=GroupUpdate( manager_config=DynamicManagerUpdate( @@ -196,7 +196,7 @@ async def test_modify_group_pattern(server, actor, participant_agents, manager_a server.group_manager.delete_group(group_id=group.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_list_agent_groups(server, actor, participant_agents): group_a = server.group_manager.create_group( group=GroupCreate( @@ -222,7 +222,7 @@ async def test_list_agent_groups(server, actor, participant_agents): server.group_manager.delete_group(group_id=group_b.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_round_robin(server, actor, participant_agents): description = ( "This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries." @@ -281,7 +281,7 @@ async def test_round_robin(server, actor, participant_agents): assert len(messages) == (len(group.agent_ids) + 2) * len(group.agent_ids) max_turns = 3 - group = server.group_manager.modify_group( + group = await server.group_manager.modify_group_async( group_id=group.id, group_update=GroupUpdate( agent_ids=[agent.id for agent in participant_agents][::-1], @@ -334,7 +334,7 @@ async def test_round_robin(server, actor, participant_agents): server.group_manager.delete_group(group_id=group.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_supervisor(server, actor, participant_agents): agent_scrappy = server.create_agent( request=CreateAgent( @@ -398,7 +398,7 @@ async def test_supervisor(server, actor, participant_agents): server.agent_manager.delete_agent(agent_id=agent_scrappy.id, actor=actor) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_dynamic_group_chat(server, actor, manager_agent, participant_agents): description = ( "This is a group chat between best friends all like to hang out together. In their free time they like to solve mysteries." diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 00000000..49fd6035 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,92 @@ +import pytest + +from letta.data_sources.redis_client import get_redis_client +from letta.helpers.decorators import experimental +from letta.settings import settings + + +@pytest.mark.asyncio +async def test_default_experimental_decorator(event_loop): + settings.plugin_register = "experimental_check=tests.helpers.plugins_helper:is_experimental_okay" + + @experimental("test_just_pass", fallback_function=lambda: False, kwarg1=3) + def _return_true(): + return True + + assert _return_true() + settings.plugin_register = "" + + +@pytest.mark.asyncio +async def test_overwrite_arg_success(event_loop): + settings.plugin_register = "experimental_check=tests.helpers.plugins_helper:is_experimental_okay" + + @experimental("test_override_kwarg", fallback_function=lambda *args, **kwargs: False, bool_val=True) + async def _return_true(a_val: bool, bool_val: bool): + assert bool_val is False + return True + + assert _return_true(False, False) + settings.plugin_register = "" + + +@pytest.mark.asyncio +async def test_overwrite_arg_fail(event_loop): + # Should fallback to lambda + settings.plugin_register = "experimental_check=tests.helpers.plugins_helper:is_experimental_okay" + + @experimental("test_override_kwarg", fallback_function=lambda *args, **kwargs: True, bool_val=False) + async def _return_false(a_val: bool, bool_val: bool): + assert bool_val is True + return False + + assert _return_false(False, True) + + @experimental("test_override_kwarg", fallback_function=lambda *args, **kwargs: False, bool_val=True) + async def _return_true(a_val: bool, bool_val: bool): + assert bool_val is False + return True + + assert _return_true(False, bool_val=False) + + @experimental("test_override_kwarg", fallback_function=lambda *args, **kwargs: True) + async def _get_true(a_val: bool, bool_val: bool): + return True + + assert await _get_true(True, bool_val=True) + with pytest.raises(Exception): + # kwarg must be included in either experimental flag or function call + assert await _get_true(True, True) + settings.plugin_register = "" + + +@pytest.mark.asyncio +async def test_redis_flag(event_loop): + settings.plugin_register = "experimental_check=tests.helpers.plugins_helper:is_experimental_okay" + + @experimental("test_redis_flag", fallback_function=lambda *args, **kwargs: _raise()) + async def _new_feature(user_id: str) -> str: + return "new_feature" + + def _raise(): + raise Exception() + + redis_client = await get_redis_client() + + group_name = "TEST_GROUP" + include_key = redis_client._get_group_inclusion_key(group_name) + exclude_key = redis_client._get_group_exclusion_key(group_name) + test_user = "user123" + # reset + for member in await redis_client.smembers(include_key): + await redis_client.srem(include_key, member) + for member in await redis_client.smembers(exclude_key): + await redis_client.srem(exclude_key, member) + + await redis_client.create_inclusion_exclusion_keys(group=group_name) + await redis_client.sadd(include_key, test_user) + + assert await _new_feature(user_id=test_user) == "new_feature" + with pytest.raises(Exception): + assert await _new_feature(user_id=test_user + "1") + print("members: ", await redis_client.smembers(include_key)) diff --git a/tests/test_redis_client.py b/tests/test_redis_client.py new file mode 100644 index 00000000..4d451a7f --- /dev/null +++ b/tests/test_redis_client.py @@ -0,0 +1,26 @@ +import pytest + +from letta.data_sources.redis_client import get_redis_client + + +@pytest.mark.asyncio +async def test_redis_client(event_loop): + test_values = {"LETTA_TEST_0": [1, 2, 3], "LETTA_TEST_1": ["apple", "pear", "banana"], "LETTA_TEST_2": ["{}", 3.2, "cat"]} + redis_client = await get_redis_client() + + # Clear out keys + await redis_client.delete(*test_values.keys()) + + # Add items + for k, v in test_values.items(): + assert await redis_client.sadd(k, *v) == 3 + + # Check Membership + for k, v in test_values.items(): + assert await redis_client.smembers(k) == set(str(val) for val in v) + + for k, v in test_values.items(): + assert await redis_client.smismember(k, "invalid") == 0 + assert await redis_client.smismember(k, v[0]) == 1 + assert await redis_client.smismember(k, v[:2]) == [1, 1] + assert await redis_client.smismember(k, v[2:] + ["invalid"]) == [1, 0] diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index f8c4185e..befab5df 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -679,72 +679,3 @@ def test_many_blocks(client: LettaSDKClient): client.agents.delete(agent1.id) client.agents.delete(agent2.id) - - -def test_sources_crud(client: LettaSDKClient, agent: AgentState): - - # Clear existing sources - for source in client.sources.list(): - client.sources.delete(source_id=source.id) - - # Clear existing jobs - for job in client.jobs.list(): - client.jobs.delete(job_id=job.id) - - # Create a new source - source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") - assert len(client.sources.list()) == 1 - - # delete the source - client.sources.delete(source_id=source.id) - assert len(client.sources.list()) == 0 - source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") - - # Load files into the source - file_a_path = "tests/data/memgpt_paper.pdf" - file_b_path = "tests/data/test.txt" - - # Upload the files - with open(file_a_path, "rb") as f: - job_a = client.sources.files.upload(source_id=source.id, file=f) - - with open(file_b_path, "rb") as f: - job_b = client.sources.files.upload(source_id=source.id, file=f) - - # Wait for the jobs to complete - while job_a.status != "completed" or job_b.status != "completed": - time.sleep(1) - job_a = client.jobs.retrieve(job_id=job_a.id) - job_b = client.jobs.retrieve(job_id=job_b.id) - print("Waiting for jobs to complete...", job_a.status, job_b.status) - - # Get the first file with pagination - files_a = client.sources.files.list(source_id=source.id, limit=1) - assert len(files_a) == 1 - assert files_a[0].source_id == source.id - - # Use the cursor from files_a to get the remaining file - files_b = client.sources.files.list(source_id=source.id, limit=1, after=files_a[-1].id) - assert len(files_b) == 1 - assert files_b[0].source_id == source.id - - # Check files are different to ensure the cursor works - assert files_a[0].file_name != files_b[0].file_name - - # Use the cursor from files_b to list files, should be empty - files = client.sources.files.list(source_id=source.id, limit=1, after=files_b[-1].id) - assert len(files) == 0 # Should be empty - - # list passages - passages = client.sources.passages.list(source_id=source.id) - assert len(passages) > 0 - - # attach to an agent - assert len(client.agents.passages.list(agent_id=agent.id)) == 0 - client.agents.sources.attach(source_id=source.id, agent_id=agent.id) - assert len(client.agents.passages.list(agent_id=agent.id)) > 0 - assert len(client.agents.sources.list(agent_id=agent.id)) == 1 - - # detach from agent - client.agents.sources.detach(source_id=source.id, agent_id=agent.id) - assert len(client.agents.passages.list(agent_id=agent.id)) == 0 diff --git a/tests/test_sources.py b/tests/test_sources.py index 62ea6b3e..c8246e23 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,4 +1,5 @@ import os +import re import threading import time @@ -8,12 +9,26 @@ from letta_client import CreateBlock from letta_client import Letta as LettaSDKClient from letta_client.types import AgentState +from letta.constants import FILES_TOOLS +from letta.orm.enums import ToolType +from letta.schemas.message import MessageCreate from tests.utils import wait_for_server # Constants SERVER_PORT = 8283 +@pytest.fixture(autouse=True) +def clear_sources_jobs(client: LettaSDKClient): + # Clear existing sources + for source in client.sources.list(): + client.sources.delete(source_id=source.id) + + # Clear existing jobs + for job in client.jobs.list(): + client.jobs.delete(job_id=job.id) + + def run_server(): load_dotenv() @@ -34,11 +49,16 @@ def client() -> LettaSDKClient: wait_for_server(server_url) print("Running client tests with server:", server_url) client = LettaSDKClient(base_url=server_url, token=None) + client.tools.upsert_base_tools() yield client @pytest.fixture def agent_state(client: LettaSDKClient): + open_file_tool = client.tools.list(name="open_file")[0] + close_file_tool = client.tools.list(name="close_file")[0] + search_files_tool = client.tools.list(name="search_files")[0] + agent_state = client.agents.create( memory_blocks=[ CreateBlock( @@ -48,18 +68,74 @@ def agent_state(client: LettaSDKClient): ], model="openai/gpt-4o-mini", embedding="openai/text-embedding-ada-002", + tool_ids=[open_file_tool.id, close_file_tool.id, search_files_tool.id], ) yield agent_state - # delete agent - client.agents.delete(agent_id=agent_state.id) + +# Tests + + +def test_auto_attach_detach_files_tools(client: LettaSDKClient): + """Test automatic attachment and detachment of file tools when managing agent sources.""" + # Create agent with basic configuration + agent = client.agents.create( + memory_blocks=[ + CreateBlock(label="human", value="username: sarah"), + ], + model="openai/gpt-4o-mini", + embedding="openai/text-embedding-ada-002", + ) + + # Helper function to get file tools from agent + def get_file_tools(agent_state): + return {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE} + + # Helper function to assert file tools presence + def assert_file_tools_present(agent_state, expected_tools): + actual_tools = get_file_tools(agent_state) + assert actual_tools == expected_tools, f"File tools mismatch.\nExpected: {expected_tools}\nFound: {actual_tools}" + + # Helper function to assert no file tools + def assert_no_file_tools(agent_state): + has_file_tools = any(tool.tool_type == ToolType.LETTA_FILES_CORE for tool in agent_state.tools) + assert not has_file_tools, "File tools should not be present" + + # Initial state: no file tools + assert_no_file_tools(agent) + + # Create and attach first source + source_1 = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") + assert len(client.sources.list()) == 1 + + agent = client.agents.sources.attach(source_id=source_1.id, agent_id=agent.id) + assert_file_tools_present(agent, set(FILES_TOOLS)) + + # Create and attach second source + source_2 = client.sources.create(name="another_test_source", embedding="openai/text-embedding-ada-002") + assert len(client.sources.list()) == 2 + + agent = client.agents.sources.attach(source_id=source_2.id, agent_id=agent.id) + # File tools should remain after attaching second source + assert_file_tools_present(agent, set(FILES_TOOLS)) + + # Detach second source - tools should remain (first source still attached) + agent = client.agents.sources.detach(source_id=source_2.id, agent_id=agent.id) + assert_file_tools_present(agent, set(FILES_TOOLS)) + + # Detach first source - all file tools should be removed + agent = client.agents.sources.detach(source_id=source_1.id, agent_id=agent.id) + assert_no_file_tools(agent) @pytest.mark.parametrize( "file_path, expected_value, expected_label_regex", [ ("tests/data/test.txt", "test", r"test_[a-z0-9]+\.txt"), - # ("tests/data/memgpt_paper.pdf", "MemGPT", r"memgpt_paper_[a-z0-9]+\.pdf"), + ("tests/data/memgpt_paper.pdf", "MemGPT", r"memgpt_paper_[a-z0-9]+\.pdf"), + ("tests/data/toy_chat_fine_tuning.jsonl", '{"messages"', r"toy_chat_fine_tuning_[a-z0-9]+\.jsonl"), + ("tests/data/test.md", "h2 Heading", r"test_[a-z0-9]+\.md"), + ("tests/data/test.json", "glossary", r"test_[a-z0-9]+\.json"), ], ) def test_file_upload_creates_source_blocks_correctly( @@ -69,14 +145,6 @@ def test_file_upload_creates_source_blocks_correctly( expected_value: str, expected_label_regex: str, ): - # Clear existing sources - for source in client.sources.list(): - client.sources.delete(source_id=source.id) - - # Clear existing jobs - for job in client.jobs.list(): - client.jobs.delete(job_id=job.id) - # Create a new source source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") assert len(client.sources.list()) == 1 @@ -99,31 +167,25 @@ def test_file_upload_creates_source_blocks_correctly( assert len(files) == 1 assert files[0].source_id == source.id - # # Check that blocks were created - # blocks = client.agents.retrieve(agent_id=agent_state.id) - # assert len(blocks) == 2 - # assert any(expected_value in b.value for b in blocks) - # assert any(re.fullmatch(expected_label_regex, b.label) for b in blocks) - # - # # Remove file from source - # client.sources.files.delete(source_id=source.id, file_id=files[0].id) - # - # # Confirm blocks were removed - # blocks = client.agents.blocks.list(agent_id=agent_state.id) - # assert len(blocks) == 1 - # assert not any(expected_value in b.value for b in blocks) - # assert not any(re.fullmatch(expected_label_regex, b.label) for b in blocks) + # Check that blocks were created + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 1 + assert any(expected_value in b.value for b in blocks) + assert any(re.fullmatch(expected_label_regex, b.label) for b in blocks) + + # Remove file from source + client.sources.files.delete(source_id=source.id, file_id=files[0].id) + + # Confirm blocks were removed + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 0 + assert not any(expected_value in b.value for b in blocks) + assert not any(re.fullmatch(expected_label_regex, b.label) for b in blocks) def test_attach_existing_files_creates_source_blocks_correctly(client: LettaSDKClient, agent_state: AgentState): - # Clear existing sources - for source in client.sources.list(): - client.sources.delete(source_id=source.id) - - # Clear existing jobs - for job in client.jobs.list(): - client.jobs.delete(job_id=job.id) - # Create a new source source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") assert len(client.sources.list()) == 1 @@ -149,31 +211,25 @@ def test_attach_existing_files_creates_source_blocks_correctly(client: LettaSDKC # Attach after uploading the file client.agents.sources.attach(source_id=source.id, agent_id=agent_state.id) - # # Get the agent state, check blocks exist - # blocks = client.agents.blocks.list(agent_id=agent_state.id) - # assert len(blocks) == 2 - # assert "test" in [b.value for b in blocks] - # assert any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) + # Get the agent state, check blocks exist + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 1 + assert any("test" in b.value for b in blocks) + assert any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) # Detach the source client.agents.sources.detach(source_id=source.id, agent_id=agent_state.id) - # # Get the agent state, check blocks do NOT exist - # blocks = client.agents.blocks.list(agent_id=agent_state.id) - # assert len(blocks) == 1 - # assert "test" not in [b.value for b in blocks] - # assert not any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) + # Get the agent state, check blocks do NOT exist + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 0 + assert not any("test" in b.value for b in blocks) + assert not any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) def test_delete_source_removes_source_blocks_correctly(client: LettaSDKClient, agent_state: AgentState): - # Clear existing sources - for source in client.sources.list(): - client.sources.delete(source_id=source.id) - - # Clear existing jobs - for job in client.jobs.list(): - client.jobs.delete(job_id=job.id) - # Create a new source source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") assert len(client.sources.list()) == 1 @@ -195,16 +251,260 @@ def test_delete_source_removes_source_blocks_correctly(client: LettaSDKClient, a print("Waiting for jobs to complete...", job.status) # Get the agent state, check blocks exist - # blocks = client.agents.blocks.list(agent_id=agent_state.id) - # assert len(blocks) == 2 - # assert "test" in [b.value for b in blocks] - # assert any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 1 + assert any("test" in b.value for b in blocks) + assert any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) # Remove file from source client.sources.delete(source_id=source.id) # Get the agent state, check blocks do NOT exist - # blocks = client.agents.blocks.list(agent_id=agent_state.id) - # assert len(blocks) == 1 - # assert "test" not in [b.value for b in blocks] - # assert not any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 0 + assert not any("test" in b.value for b in blocks) + assert not any(re.fullmatch(r"test_[a-z0-9]+\.txt", b.label) for b in blocks) + + +def test_agent_uses_open_close_file_correctly(client: LettaSDKClient, agent_state: AgentState): + # Create a new source + source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") + + sources_list = client.sources.list() + assert len(sources_list) == 1 + + # Attach source to agent + client.agents.sources.attach(source_id=source.id, agent_id=agent_state.id) + + # Load files into the source + file_path = "tests/data/long_test.txt" + + # Upload the files + with open(file_path, "rb") as f: + job = client.sources.files.upload(source_id=source.id, file=f) + + # Wait for the jobs to complete + while job.status != "completed": + print(f"Waiting for job {job.id} to complete... Current status: {job.status}") + time.sleep(1) + job = client.jobs.retrieve(job_id=job.id) + + # Get uploaded files + files = client.sources.files.list(source_id=source.id, limit=1) + assert len(files) == 1 + assert files[0].source_id == source.id + file = files[0] + + # Check that file is opened initially + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + print(f"Agent has {len(blocks)} file block(s)") + if blocks: + initial_content_length = len(blocks[0].value) + print(f"Initial file content length: {initial_content_length} characters") + print(f"First 100 chars of content: {blocks[0].value[:100]}...") + assert initial_content_length > 10, f"Expected file content > 10 chars, got {initial_content_length}" + + # Ask agent to close the file + print(f"Requesting agent to close file: {file.file_name}") + close_response = client.agents.messages.create( + agent_id=agent_state.id, + messages=[MessageCreate(role="user", content=f"Use ONLY the close_file tool to close the file named {file.file_name}")], + ) + print(f"Close file request sent, got {len(close_response.messages)} message(s) in response") + print(close_response.messages) + + # Check that file is closed + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + closed_content_length = len(blocks[0].value) if blocks else 0 + print(f"File content length after close: {closed_content_length} characters") + assert closed_content_length == 0, f"Expected empty content after close, got {closed_content_length} chars" + + # Ask agent to open the file for a specific range + start, end = 0, 5 + print(f"Requesting agent to open file for range [{start}, {end}]") + open_response1 = client.agents.messages.create( + agent_id=agent_state.id, + messages=[ + MessageCreate( + role="user", content=f"Use ONLY the open_file tool to open the file named {file.file_name} for view range [{start}, {end}]" + ) + ], + ) + print(f"First open request sent, got {len(open_response1.messages)} message(s) in response") + print(open_response1.messages) + + # Check that file is opened + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + old_value = blocks[0].value + old_content_length = len(old_value) + print(f"File content length after first open: {old_content_length} characters") + print(f"First range content: '{old_value}'") + assert old_content_length > 10, f"Expected content > 10 chars for range [{start}, {end}], got {old_content_length}" + + # Ask agent to open the file for a different range + start, end = 5, 10 + open_response2 = client.agents.messages.create( + agent_id=agent_state.id, + messages=[ + MessageCreate( + role="user", content=f"Use ONLY the open_file tool to open the file named {file.file_name} for view range [{start}, {end}]" + ) + ], + ) + print(f"Second open request sent, got {len(open_response2.messages)} message(s) in response") + print(open_response2.messages) + + # Check that file is opened, but for different range + print("Verifying file is opened with second range...") + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + new_value = blocks[0].value + new_content_length = len(new_value) + print(f"File content length after second open: {new_content_length} characters") + print(f"Second range content: '{new_value}'") + assert new_content_length > 10, f"Expected content > 10 chars for range [{start}, {end}], got {new_content_length}" + + print(f"Comparing content ranges:") + print(f" First range [0, 5]: '{old_value}'") + print(f" Second range [5, 10]: '{new_value}'") + + assert new_value != old_value, f"Different view ranges should have different content. New: '{new_value}', Old: '{old_value}'" + print("✓ File successfully opened with different range - content differs as expected") + + +def test_agent_uses_search_files_correctly(client: LettaSDKClient, agent_state: AgentState): + # Create a new source + source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") + + sources_list = client.sources.list() + assert len(sources_list) == 1 + + # Attach source to agent + client.agents.sources.attach(source_id=source.id, agent_id=agent_state.id) + + # Load files into the source + file_path = "tests/data/long_test.txt" + print(f"Uploading file: {file_path}") + + # Upload the files + with open(file_path, "rb") as f: + job = client.sources.files.upload(source_id=source.id, file=f) + + print(f"File upload job created with ID: {job.id}, initial status: {job.status}") + + # Wait for the jobs to complete + while job.status != "completed": + print(f"Waiting for job {job.id} to complete... Current status: {job.status}") + time.sleep(1) + job = client.jobs.retrieve(job_id=job.id) + + # Get uploaded files + files = client.sources.files.list(source_id=source.id, limit=1) + assert len(files) == 1 + assert files[0].source_id == source.id + files[0] + + # Check that file is opened initially + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + print(f"Agent has {len(blocks)} file block(s)") + if blocks: + initial_content_length = len(blocks[0].value) + print(f"Initial file content length: {initial_content_length} characters") + print(f"First 100 chars of content: {blocks[0].value[:100]}...") + assert initial_content_length > 10, f"Expected file content > 10 chars, got {initial_content_length}" + print("✓ File appears to be initially loaded") + + # Ask agent to use the search_files tool + search_files_response = client.agents.messages.create( + agent_id=agent_state.id, + messages=[ + MessageCreate(role="user", content=f"Use ONLY the search_files tool to search for details regarding the electoral history.") + ], + ) + print(f"Search file request sent, got {len(search_files_response.messages)} message(s) in response") + print(search_files_response.messages) + + # Check that archival_memory_search was called + tool_calls = [msg for msg in search_files_response.messages if msg.message_type == "tool_call_message"] + assert len(tool_calls) > 0, "No tool calls found" + assert any(tc.tool_call.name == "search_files" for tc in tool_calls), "search_files not called" + + # Check it returned successfully + tool_returns = [msg for msg in search_files_response.messages if msg.message_type == "tool_return_message"] + assert len(tool_returns) > 0, "No tool returns found" + assert all(tr.status == "success" for tr in tool_returns), "Tool call failed" + + +def test_view_ranges_have_metadata(client: LettaSDKClient, agent_state: AgentState): + # Create a new source + source = client.sources.create(name="test_source", embedding="openai/text-embedding-ada-002") + + sources_list = client.sources.list() + assert len(sources_list) == 1 + + # Attach source to agent + client.agents.sources.attach(source_id=source.id, agent_id=agent_state.id) + + # Load files into the source + file_path = "tests/data/lines_1_to_100.txt" + + # Upload the files + with open(file_path, "rb") as f: + job = client.sources.files.upload(source_id=source.id, file=f) + + # Wait for the jobs to complete + while job.status != "completed": + print(f"Waiting for job {job.id} to complete... Current status: {job.status}") + time.sleep(1) + job = client.jobs.retrieve(job_id=job.id) + + # Get uploaded files + files = client.sources.files.list(source_id=source.id, limit=1) + assert len(files) == 1 + assert files[0].source_id == source.id + file = files[0] + + # Check that file is opened initially + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 1 + block = blocks[0] + assert block.value.startswith("[Viewing file start (out of 100 lines)]") + + # Open a specific range + start = 50 + end = 55 + open_response = client.agents.messages.create( + agent_id=agent_state.id, + messages=[ + MessageCreate( + role="user", content=f"Use ONLY the open_file tool to open the file named {file.file_name} for view range [{start}, {end}]" + ) + ], + ) + print(f"Open request sent, got {len(open_response.messages)} message(s) in response") + print(open_response.messages) + + # Check that file is opened correctly + agent_state = client.agents.retrieve(agent_id=agent_state.id) + blocks = agent_state.memory.file_blocks + assert len(blocks) == 1 + block = blocks[0] + print(block.value) + assert ( + block.value + == """ + [Viewing lines 50 to 55 (out of 100 lines)] +Line 50: Line 51 +Line 51: Line 52 +Line 52: Line 53 +Line 53: Line 54 +Line 54: Line 55 + """.strip() + )