This commit is contained in:
Sarah Wooders
2025-06-06 14:46:21 -07:00
118 changed files with 5774 additions and 2065 deletions

View File

@@ -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}

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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"]

View File

@@ -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__)

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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"

View File

View File

@@ -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

View File

@@ -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"""

View File

@@ -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.")

View File

@@ -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)

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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__)

View File

@@ -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)

View File

@@ -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__)

View File

@@ -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": <type>
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)}")

View File

@@ -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"]

View File

@@ -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

View File

@@ -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__)

View File

@@ -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(

View File

@@ -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

View File

@@ -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:

View File

@@ -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?

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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)

0
letta/otel/__init__.py Normal file
View File

25
letta/otel/context.py Normal file
View File

@@ -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()

0
letta/otel/events.py Normal file
View File

View File

@@ -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",
),
)

66
letta/otel/metrics.py Normal file
View File

@@ -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

26
letta/otel/resource.py Normal file
View File

@@ -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

View File

@@ -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<agent_id>[^/]+)/messages$",
# "^GET /v1/agents/(?P<agent_id>[^/]+)/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):

22
letta/plugins/README.md Normal file
View File

@@ -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:
`<plugin_name>.<config_name>=<class_or_function>`
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 `<module_path>:<name>`.
```
DEFAULT_PLUGINS = {
"experimental_check": {
"default": "letta.plugins.defaults:is_experimental_enabled",
...
```

View File

11
letta/plugins/defaults.py Normal file
View File

@@ -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

72
letta/plugins/plugins.py Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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,

View File

@@ -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))

View File

@@ -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.

View File

@@ -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:

View File

@@ -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:
"""

View File

@@ -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:

View File

@@ -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__)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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__)

View File

@@ -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__)

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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__)

View File

@@ -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()

View File

@@ -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

View File

@@ -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__)

View File

@@ -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

View File

@@ -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 dont 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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,
)

View File

@@ -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 systemmessage
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 ["<no response>"],
}
except Exception as e:
return {
"agent_id": agent_id,
"error": str(e),
"type": type(e).__name__,
}

View File

@@ -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))

View File

@@ -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__)

View File

@@ -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 systemmessage
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 ["<no response>"],
}
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 dont 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)

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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]):
"""

View File

@@ -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

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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]

632
poetry.lock generated
View File

@@ -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"

View File

@@ -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]

View File

@@ -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

412
tests/data/long_test.txt Normal file
View File

@@ -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 Universitys 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 (199899)
Minister of Industry (19992001)
Prime Minister of Italy (201314)
Democratic Party Secretary (2021present)
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, lallievo 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, 18612011. 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. Lattentatore: "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 dellantipatia", 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 hell 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
19981999 Succeeded by
Patrizia Toia
Preceded by
Pier Luigi Bersani
Minister of Industry, Commerce and Crafts
19992001 Succeeded by
Antonio Marzano
as Minister of Productive Activities
Preceded by
Gianni Letta
Secretary of the Council of Ministers
20062008 Succeeded by
Gianni Letta
Preceded by
Mario Monti
Prime Minister of Italy
20132014 Succeeded by
Matteo Renzi
Party political offices
Preceded by
Dario Franceschini
Deputy Secretary of the Democratic Party
20092013 Succeeded by
Debora Serracchiani
Succeeded by
Lorenzo Guerini
Preceded by
Nicola Zingaretti
Secretary of the Democratic Party
20212023 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 20042009University of Pisa alumniSant'Anna School of Advanced Studies alumniLeaders of political parties in Italy

22
tests/data/test.json Normal file
View File

@@ -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"
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More