merge
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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"]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
0
letta/data_sources/__init__.py
Normal file
0
letta/data_sources/__init__.py
Normal file
282
letta/data_sources/redis_client.py
Normal file
282
letta/data_sources/redis_client.py
Normal 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
|
||||
@@ -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"""
|
||||
|
||||
|
||||
58
letta/functions/function_sets/files.py
Normal file
58
letta/functions/function_sets/files.py
Normal 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.")
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
69
letta/helpers/decorators.py
Normal file
69
letta/helpers/decorators.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
0
letta/otel/__init__.py
Normal file
25
letta/otel/context.py
Normal file
25
letta/otel/context.py
Normal 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
0
letta/otel/events.py
Normal file
122
letta/otel/metric_registry.py
Normal file
122
letta/otel/metric_registry.py
Normal 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
66
letta/otel/metrics.py
Normal 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
26
letta/otel/resource.py
Normal 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
|
||||
@@ -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
22
letta/plugins/README.md
Normal 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",
|
||||
...
|
||||
```
|
||||
0
letta/plugins/__init__.py
Normal file
0
letta/plugins/__init__.py
Normal file
11
letta/plugins/defaults.py
Normal file
11
letta/plugins/defaults.py
Normal 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
72
letta/plugins/plugins.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
34
letta/services/file_processor/chunker/line_chunker.py
Normal file
34
letta/services/file_processor/chunker/line_chunker.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
117
letta/services/tool_executor/builtin_tool_executor.py
Normal file
117
letta/services/tool_executor/builtin_tool_executor.py
Normal 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 don’t blow up context
|
||||
content = (
|
||||
shorten(item.get("content", "").strip(), width=600, placeholder=" …")
|
||||
if WEB_SEARCH_CLIP_CONTENT
|
||||
else item.get("content", "").strip()
|
||||
)
|
||||
score = item.get("score")
|
||||
if WEB_SEARCH_INCLUDE_SCORE:
|
||||
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n"
|
||||
else:
|
||||
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n"
|
||||
formatted_blocks.append(block)
|
||||
|
||||
return WEB_SEARCH_SEPARATOR.join(formatted_blocks)
|
||||
53
letta/services/tool_executor/composio_tool_executor.py
Normal file
53
letta/services/tool_executor/composio_tool_executor.py
Normal 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
|
||||
474
letta/services/tool_executor/core_tool_executor.py
Normal file
474
letta/services/tool_executor/core_tool_executor.py
Normal 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
|
||||
131
letta/services/tool_executor/files_tool_executor.py
Normal file
131
letta/services/tool_executor/files_tool_executor.py
Normal 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]
|
||||
45
letta/services/tool_executor/mcp_tool_executor.py
Normal file
45
letta/services/tool_executor/mcp_tool_executor.py
Normal 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,
|
||||
)
|
||||
123
letta/services/tool_executor/multi_agent_tool_executor.py
Normal file
123
letta/services/tool_executor/multi_agent_tool_executor.py
Normal 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 system‐message
|
||||
prefixed = (
|
||||
f"[Incoming message from agent with ID '{agent_state.id}' - "
|
||||
f"to reply to this message, make sure to use the "
|
||||
f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
|
||||
|
||||
task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
|
||||
|
||||
return "Successfully sent message"
|
||||
|
||||
async def send_message_to_agents_matching_tags_async(
|
||||
self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str]
|
||||
) -> str:
|
||||
# Find matching agents
|
||||
matching_agents = await self.agent_manager.list_agents_matching_tags_async(
|
||||
actor=self.actor, match_all=match_all, match_some=match_some
|
||||
)
|
||||
if not matching_agents:
|
||||
return str([])
|
||||
|
||||
augmented_message = (
|
||||
"[Incoming message from external Letta agent - to reply to this message, "
|
||||
"make sure to use the 'send_message' at the end, and the system will notify "
|
||||
"the sender of your response] "
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return str(results)
|
||||
|
||||
async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]:
|
||||
from letta.agents.letta_agent import LettaAgent
|
||||
|
||||
try:
|
||||
letta_agent = LettaAgent(
|
||||
agent_id=agent_id,
|
||||
message_manager=self.message_manager,
|
||||
agent_manager=self.agent_manager,
|
||||
block_manager=self.block_manager,
|
||||
passage_manager=self.passage_manager,
|
||||
actor=self.actor,
|
||||
)
|
||||
|
||||
letta_response = await letta_agent.step([MessageCreate(role=MessageRole.system, content=[TextContent(text=message)])])
|
||||
messages = letta_response.messages
|
||||
|
||||
send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)]
|
||||
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"response": send_message_content if send_message_content else ["<no response>"],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"error": str(e),
|
||||
"type": type(e).__name__,
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -1,720 +1,25 @@
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import traceback
|
||||
from abc import ABC, abstractmethod
|
||||
from textwrap import shorten
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from letta.constants import (
|
||||
COMPOSIO_ENTITY_ENV_VAR_KEY,
|
||||
CORE_MEMORY_LINE_NUMBER_WARNING,
|
||||
MCP_TOOL_TAG_NAME_PREFIX,
|
||||
MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX,
|
||||
READ_ONLY_BLOCK_EDIT_ERROR,
|
||||
RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE,
|
||||
WEB_SEARCH_CLIP_CONTENT,
|
||||
WEB_SEARCH_INCLUDE_SCORE,
|
||||
WEB_SEARCH_SEPARATOR,
|
||||
)
|
||||
from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_function_annotations_from_source
|
||||
from letta.functions.composio_helpers import execute_composio_action_async, generate_composio_action_from_func_name
|
||||
from letta.helpers.composio_helpers import get_composio_api_key_async
|
||||
from letta.helpers.json_helpers import json_dumps
|
||||
from letta.log import get_logger
|
||||
from letta.otel.tracing import trace_method
|
||||
from letta.schemas.agent import AgentState
|
||||
from letta.schemas.enums import MessageRole
|
||||
from letta.schemas.letta_message import AssistantMessage
|
||||
from letta.schemas.letta_message_content import TextContent
|
||||
from letta.schemas.message import MessageCreate
|
||||
from letta.schemas.sandbox_config import SandboxConfig
|
||||
from letta.schemas.tool import Tool
|
||||
from letta.schemas.tool_execution_result import ToolExecutionResult
|
||||
from letta.schemas.user import User
|
||||
from letta.services.agent_manager import AgentManager
|
||||
from letta.services.block_manager import BlockManager
|
||||
from letta.services.mcp_manager import MCPManager
|
||||
from letta.services.message_manager import MessageManager
|
||||
from letta.services.passage_manager import PassageManager
|
||||
from letta.services.tool_executor.tool_executor_base import ToolExecutor
|
||||
from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
|
||||
from letta.services.tool_sandbox.local_sandbox import AsyncToolSandboxLocal
|
||||
from letta.settings import tool_settings
|
||||
from letta.tracing import trace_method
|
||||
from letta.types import JsonDict
|
||||
from letta.utils import get_friendly_error_msg
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ToolExecutor(ABC):
|
||||
"""Abstract base class for tool executors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_manager: MessageManager,
|
||||
agent_manager: AgentManager,
|
||||
block_manager: BlockManager,
|
||||
passage_manager: PassageManager,
|
||||
actor: User,
|
||||
):
|
||||
self.message_manager = message_manager
|
||||
self.agent_manager = agent_manager
|
||||
self.block_manager = block_manager
|
||||
self.passage_manager = passage_manager
|
||||
self.actor = actor
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
tool: Tool,
|
||||
actor: User,
|
||||
agent_state: Optional[AgentState] = None,
|
||||
sandbox_config: Optional[SandboxConfig] = None,
|
||||
sandbox_env_vars: Optional[Dict[str, Any]] = None,
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute the tool and return the result."""
|
||||
|
||||
|
||||
class LettaCoreToolExecutor(ToolExecutor):
|
||||
"""Executor for LETTA core tools with direct implementation of functions."""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
tool: Tool,
|
||||
actor: User,
|
||||
agent_state: Optional[AgentState] = None,
|
||||
sandbox_config: Optional[SandboxConfig] = None,
|
||||
sandbox_env_vars: Optional[Dict[str, Any]] = None,
|
||||
) -> ToolExecutionResult:
|
||||
# Map function names to method calls
|
||||
assert agent_state is not None, "Agent state is required for core tools"
|
||||
function_map = {
|
||||
"send_message": self.send_message,
|
||||
"conversation_search": self.conversation_search,
|
||||
"archival_memory_search": self.archival_memory_search,
|
||||
"archival_memory_insert": self.archival_memory_insert,
|
||||
"core_memory_append": self.core_memory_append,
|
||||
"core_memory_replace": self.core_memory_replace,
|
||||
"memory_replace": self.memory_replace,
|
||||
"memory_insert": self.memory_insert,
|
||||
"memory_rethink": self.memory_rethink,
|
||||
"memory_finish_edits": self.memory_finish_edits,
|
||||
}
|
||||
|
||||
if function_name not in function_map:
|
||||
raise ValueError(f"Unknown function: {function_name}")
|
||||
|
||||
# Execute the appropriate function
|
||||
function_args_copy = function_args.copy() # Make a copy to avoid modifying the original
|
||||
try:
|
||||
function_response = await function_map[function_name](agent_state, actor, **function_args_copy)
|
||||
return ToolExecutionResult(
|
||||
status="success",
|
||||
func_return=function_response,
|
||||
agent_state=agent_state,
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolExecutionResult(
|
||||
status="error",
|
||||
func_return=e,
|
||||
agent_state=agent_state,
|
||||
stderr=[get_friendly_error_msg(function_name=function_name, exception_name=type(e).__name__, exception_message=str(e))],
|
||||
)
|
||||
|
||||
async def send_message(self, agent_state: AgentState, actor: User, message: str) -> Optional[str]:
|
||||
"""
|
||||
Sends a message to the human user.
|
||||
|
||||
Args:
|
||||
message (str): Message contents. All unicode (including emojis) are supported.
|
||||
|
||||
Returns:
|
||||
Optional[str]: None is always returned as this function does not produce a response.
|
||||
"""
|
||||
return "Sent message successfully."
|
||||
|
||||
async def conversation_search(self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0) -> Optional[str]:
|
||||
"""
|
||||
Search prior conversation history using case-insensitive string matching.
|
||||
|
||||
Args:
|
||||
query (str): String to search for.
|
||||
page (int): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page).
|
||||
|
||||
Returns:
|
||||
str: Query result string
|
||||
"""
|
||||
if page is None or (isinstance(page, str) and page.lower().strip() == "none"):
|
||||
page = 0
|
||||
try:
|
||||
page = int(page)
|
||||
except:
|
||||
raise ValueError(f"'page' argument must be an integer")
|
||||
|
||||
count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
|
||||
messages = await MessageManager().list_user_messages_for_agent_async(
|
||||
agent_id=agent_state.id,
|
||||
actor=actor,
|
||||
query_text=query,
|
||||
limit=count,
|
||||
)
|
||||
|
||||
total = len(messages)
|
||||
num_pages = math.ceil(total / count) - 1 # 0 index
|
||||
|
||||
if len(messages) == 0:
|
||||
results_str = f"No results found."
|
||||
else:
|
||||
results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):"
|
||||
results_formatted = [message.content[0].text for message in messages]
|
||||
results_str = f"{results_pref} {json_dumps(results_formatted)}"
|
||||
|
||||
return results_str
|
||||
|
||||
async def archival_memory_search(
|
||||
self, agent_state: AgentState, actor: User, query: str, page: Optional[int] = 0, start: Optional[int] = 0
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Search archival memory using semantic (embedding-based) search.
|
||||
|
||||
Args:
|
||||
query (str): String to search for.
|
||||
page (Optional[int]): Allows you to page through results. Only use on a follow-up query. Defaults to 0 (first page).
|
||||
start (Optional[int]): Starting index for the search results. Defaults to 0.
|
||||
|
||||
Returns:
|
||||
str: Query result string
|
||||
"""
|
||||
if page is None or (isinstance(page, str) and page.lower().strip() == "none"):
|
||||
page = 0
|
||||
try:
|
||||
page = int(page)
|
||||
except:
|
||||
raise ValueError(f"'page' argument must be an integer")
|
||||
|
||||
count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
|
||||
|
||||
try:
|
||||
# Get results using passage manager
|
||||
all_results = await AgentManager().list_passages_async(
|
||||
actor=actor,
|
||||
agent_id=agent_state.id,
|
||||
query_text=query,
|
||||
limit=count + start, # Request enough results to handle offset
|
||||
embedding_config=agent_state.embedding_config,
|
||||
embed_query=True,
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
end = min(count + start, len(all_results))
|
||||
paged_results = all_results[start:end]
|
||||
|
||||
# Format results to match previous implementation
|
||||
formatted_results = [{"timestamp": str(result.created_at), "content": result.text} for result in paged_results]
|
||||
|
||||
return formatted_results, len(formatted_results)
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def archival_memory_insert(self, agent_state: AgentState, actor: User, content: str) -> Optional[str]:
|
||||
"""
|
||||
Add to archival memory. Make sure to phrase the memory contents such that it can be easily queried later.
|
||||
|
||||
Args:
|
||||
content (str): Content to write to the memory. All unicode (including emojis) are supported.
|
||||
|
||||
Returns:
|
||||
Optional[str]: None is always returned as this function does not produce a response.
|
||||
"""
|
||||
await PassageManager().insert_passage_async(
|
||||
agent_state=agent_state,
|
||||
agent_id=agent_state.id,
|
||||
text=content,
|
||||
actor=actor,
|
||||
)
|
||||
await AgentManager().rebuild_system_prompt_async(agent_id=agent_state.id, actor=actor, force=True)
|
||||
return None
|
||||
|
||||
async def core_memory_append(self, agent_state: AgentState, actor: User, label: str, content: str) -> Optional[str]:
|
||||
"""
|
||||
Append to the contents of core memory.
|
||||
|
||||
Args:
|
||||
label (str): Section of the memory to be edited (persona or human).
|
||||
content (str): Content to write to the memory. All unicode (including emojis) are supported.
|
||||
|
||||
Returns:
|
||||
Optional[str]: None is always returned as this function does not produce a response.
|
||||
"""
|
||||
if agent_state.memory.get_block(label).read_only:
|
||||
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
|
||||
current_value = str(agent_state.memory.get_block(label).value)
|
||||
new_value = current_value + "\n" + str(content)
|
||||
agent_state.memory.update_block_value(label=label, value=new_value)
|
||||
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
||||
return None
|
||||
|
||||
async def core_memory_replace(
|
||||
self,
|
||||
agent_state: AgentState,
|
||||
actor: User,
|
||||
label: str,
|
||||
old_content: str,
|
||||
new_content: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Replace the contents of core memory. To delete memories, use an empty string for new_content.
|
||||
|
||||
Args:
|
||||
label (str): Section of the memory to be edited (persona or human).
|
||||
old_content (str): String to replace. Must be an exact match.
|
||||
new_content (str): Content to write to the memory. All unicode (including emojis) are supported.
|
||||
|
||||
Returns:
|
||||
Optional[str]: None is always returned as this function does not produce a response.
|
||||
"""
|
||||
if agent_state.memory.get_block(label).read_only:
|
||||
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
|
||||
current_value = str(agent_state.memory.get_block(label).value)
|
||||
if old_content not in current_value:
|
||||
raise ValueError(f"Old content '{old_content}' not found in memory block '{label}'")
|
||||
new_value = current_value.replace(str(old_content), str(new_content))
|
||||
agent_state.memory.update_block_value(label=label, value=new_value)
|
||||
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
||||
return None
|
||||
|
||||
async def memory_replace(
|
||||
self,
|
||||
agent_state: AgentState,
|
||||
actor: User,
|
||||
label: str,
|
||||
old_str: str,
|
||||
new_str: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
The memory_replace command allows you to replace a specific string in a memory
|
||||
block with a new string. This is used for making precise edits.
|
||||
|
||||
Args:
|
||||
label (str): Section of the memory to be edited, identified by its label.
|
||||
old_str (str): The text to replace (must match exactly, including whitespace
|
||||
and indentation). Do not include line number prefixes.
|
||||
new_str (Optional[str]): The new text to insert in place of the old text.
|
||||
Omit this argument to delete the old_str. Do not include line number prefixes.
|
||||
|
||||
Returns:
|
||||
str: The success message
|
||||
"""
|
||||
|
||||
if agent_state.memory.get_block(label).read_only:
|
||||
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
|
||||
|
||||
if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(old_str)):
|
||||
raise ValueError(
|
||||
"old_str contains a line number prefix, which is not allowed. "
|
||||
"Do not include line numbers when calling memory tools (line "
|
||||
"numbers are for display purposes only)."
|
||||
)
|
||||
if CORE_MEMORY_LINE_NUMBER_WARNING in old_str:
|
||||
raise ValueError(
|
||||
"old_str contains a line number warning, which is not allowed. "
|
||||
"Do not include line number information when calling memory tools "
|
||||
"(line numbers are for display purposes only)."
|
||||
)
|
||||
if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)):
|
||||
raise ValueError(
|
||||
"new_str contains a line number prefix, which is not allowed. "
|
||||
"Do not include line numbers when calling memory tools (line "
|
||||
"numbers are for display purposes only)."
|
||||
)
|
||||
|
||||
old_str = str(old_str).expandtabs()
|
||||
new_str = str(new_str).expandtabs()
|
||||
current_value = str(agent_state.memory.get_block(label).value).expandtabs()
|
||||
|
||||
# Check if old_str is unique in the block
|
||||
occurences = current_value.count(old_str)
|
||||
if occurences == 0:
|
||||
raise ValueError(
|
||||
f"No replacement was performed, old_str `{old_str}` did not appear " f"verbatim in memory block with label `{label}`."
|
||||
)
|
||||
elif occurences > 1:
|
||||
content_value_lines = current_value.split("\n")
|
||||
lines = [idx + 1 for idx, line in enumerate(content_value_lines) if old_str in line]
|
||||
raise ValueError(
|
||||
f"No replacement was performed. Multiple occurrences of "
|
||||
f"old_str `{old_str}` in lines {lines}. Please ensure it is unique."
|
||||
)
|
||||
|
||||
# Replace old_str with new_str
|
||||
new_value = current_value.replace(str(old_str), str(new_str))
|
||||
|
||||
# Write the new content to the block
|
||||
agent_state.memory.update_block_value(label=label, value=new_value)
|
||||
|
||||
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
||||
|
||||
# Create a snippet of the edited section
|
||||
SNIPPET_LINES = 3
|
||||
replacement_line = current_value.split(old_str)[0].count("\n")
|
||||
start_line = max(0, replacement_line - SNIPPET_LINES)
|
||||
end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")
|
||||
snippet = "\n".join(new_value.split("\n")[start_line : end_line + 1])
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f"The core memory block with label `{label}` has been edited. "
|
||||
# success_msg += self._make_output(
|
||||
# snippet, f"a snippet of {path}", start_line + 1
|
||||
# )
|
||||
# success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n"
|
||||
success_msg += (
|
||||
"Review the changes and make sure they are as expected (correct indentation, "
|
||||
"no duplicate lines, etc). Edit the memory block again if necessary."
|
||||
)
|
||||
|
||||
# return None
|
||||
return success_msg
|
||||
|
||||
async def memory_insert(
|
||||
self,
|
||||
agent_state: AgentState,
|
||||
actor: User,
|
||||
label: str,
|
||||
new_str: str,
|
||||
insert_line: int = -1,
|
||||
) -> str:
|
||||
"""
|
||||
The memory_insert command allows you to insert text at a specific location
|
||||
in a memory block.
|
||||
|
||||
Args:
|
||||
label (str): Section of the memory to be edited, identified by its label.
|
||||
new_str (str): The text to insert. Do not include line number prefixes.
|
||||
insert_line (int): The line number after which to insert the text (0 for
|
||||
beginning of file). Defaults to -1 (end of the file).
|
||||
|
||||
Returns:
|
||||
str: The success message
|
||||
"""
|
||||
|
||||
if agent_state.memory.get_block(label).read_only:
|
||||
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
|
||||
|
||||
if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_str)):
|
||||
raise ValueError(
|
||||
"new_str contains a line number prefix, which is not allowed. Do not "
|
||||
"include line numbers when calling memory tools (line numbers are for "
|
||||
"display purposes only)."
|
||||
)
|
||||
if CORE_MEMORY_LINE_NUMBER_WARNING in new_str:
|
||||
raise ValueError(
|
||||
"new_str contains a line number warning, which is not allowed. Do not "
|
||||
"include line number information when calling memory tools (line numbers "
|
||||
"are for display purposes only)."
|
||||
)
|
||||
|
||||
current_value = str(agent_state.memory.get_block(label).value).expandtabs()
|
||||
new_str = str(new_str).expandtabs()
|
||||
current_value_lines = current_value.split("\n")
|
||||
n_lines = len(current_value_lines)
|
||||
|
||||
# Check if we're in range, from 0 (pre-line), to 1 (first line), to n_lines (last line)
|
||||
if insert_line == -1:
|
||||
insert_line = n_lines
|
||||
elif insert_line < 0 or insert_line > n_lines:
|
||||
raise ValueError(
|
||||
f"Invalid `insert_line` parameter: {insert_line}. It should be within "
|
||||
f"the range of lines of the memory block: {[0, n_lines]}, or -1 to "
|
||||
f"append to the end of the memory block."
|
||||
)
|
||||
|
||||
# Insert the new string as a line
|
||||
SNIPPET_LINES = 3
|
||||
new_str_lines = new_str.split("\n")
|
||||
new_value_lines = current_value_lines[:insert_line] + new_str_lines + current_value_lines[insert_line:]
|
||||
snippet_lines = (
|
||||
current_value_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
|
||||
+ new_str_lines
|
||||
+ current_value_lines[insert_line : insert_line + SNIPPET_LINES]
|
||||
)
|
||||
|
||||
# Collate into the new value to update
|
||||
new_value = "\n".join(new_value_lines)
|
||||
snippet = "\n".join(snippet_lines)
|
||||
|
||||
# Write into the block
|
||||
agent_state.memory.update_block_value(label=label, value=new_value)
|
||||
|
||||
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f"The core memory block with label `{label}` has been edited. "
|
||||
# success_msg += self._make_output(
|
||||
# snippet,
|
||||
# "a snippet of the edited file",
|
||||
# max(1, insert_line - SNIPPET_LINES + 1),
|
||||
# )
|
||||
# success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n"
|
||||
success_msg += (
|
||||
"Review the changes and make sure they are as expected (correct indentation, "
|
||||
"no duplicate lines, etc). Edit the memory block again if necessary."
|
||||
)
|
||||
|
||||
return success_msg
|
||||
|
||||
async def memory_rethink(self, agent_state: AgentState, actor: User, label: str, new_memory: str) -> str:
|
||||
"""
|
||||
The memory_rethink command allows you to completely rewrite the contents of a
|
||||
memory block. Use this tool to make large sweeping changes (e.g. when you want
|
||||
to condense or reorganize the memory blocks), do NOT use this tool to make small
|
||||
precise edits (e.g. add or remove a line, replace a specific string, etc).
|
||||
|
||||
Args:
|
||||
label (str): The memory block to be rewritten, identified by its label.
|
||||
new_memory (str): The new memory contents with information integrated from
|
||||
existing memory blocks and the conversation context. Do not include line number prefixes.
|
||||
|
||||
Returns:
|
||||
str: The success message
|
||||
"""
|
||||
if agent_state.memory.get_block(label).read_only:
|
||||
raise ValueError(f"{READ_ONLY_BLOCK_EDIT_ERROR}")
|
||||
|
||||
if bool(MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX.search(new_memory)):
|
||||
raise ValueError(
|
||||
"new_memory contains a line number prefix, which is not allowed. Do not "
|
||||
"include line numbers when calling memory tools (line numbers are for "
|
||||
"display purposes only)."
|
||||
)
|
||||
if CORE_MEMORY_LINE_NUMBER_WARNING in new_memory:
|
||||
raise ValueError(
|
||||
"new_memory contains a line number warning, which is not allowed. Do not "
|
||||
"include line number information when calling memory tools (line numbers "
|
||||
"are for display purposes only)."
|
||||
)
|
||||
|
||||
if agent_state.memory.get_block(label) is None:
|
||||
agent_state.memory.create_block(label=label, value=new_memory)
|
||||
|
||||
agent_state.memory.update_block_value(label=label, value=new_memory)
|
||||
|
||||
await AgentManager().update_memory_if_changed_async(agent_id=agent_state.id, new_memory=agent_state.memory, actor=actor)
|
||||
|
||||
# Prepare the success message
|
||||
success_msg = f"The core memory block with label `{label}` has been edited. "
|
||||
# success_msg += self._make_output(
|
||||
# snippet, f"a snippet of {path}", start_line + 1
|
||||
# )
|
||||
# success_msg += f"A snippet of core memory block `{label}`:\n{snippet}\n"
|
||||
success_msg += (
|
||||
"Review the changes and make sure they are as expected (correct indentation, "
|
||||
"no duplicate lines, etc). Edit the memory block again if necessary."
|
||||
)
|
||||
|
||||
# return None
|
||||
return success_msg
|
||||
|
||||
async def memory_finish_edits(self, agent_state: AgentState, actor: User) -> None:
|
||||
"""
|
||||
Call the memory_finish_edits command when you are finished making edits
|
||||
(integrating all new information) into the memory blocks. This function
|
||||
is called when the agent is done rethinking the memory.
|
||||
|
||||
Returns:
|
||||
Optional[str]: None is always returned as this function does not produce a response.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class LettaMultiAgentToolExecutor(ToolExecutor):
|
||||
"""Executor for LETTA multi-agent core tools."""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
tool: Tool,
|
||||
actor: User,
|
||||
agent_state: Optional[AgentState] = None,
|
||||
sandbox_config: Optional[SandboxConfig] = None,
|
||||
sandbox_env_vars: Optional[Dict[str, Any]] = None,
|
||||
) -> ToolExecutionResult:
|
||||
assert agent_state is not None, "Agent state is required for multi-agent tools"
|
||||
function_map = {
|
||||
"send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply,
|
||||
"send_message_to_agent_async": self.send_message_to_agent_async,
|
||||
"send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async,
|
||||
}
|
||||
|
||||
if function_name not in function_map:
|
||||
raise ValueError(f"Unknown function: {function_name}")
|
||||
|
||||
# Execute the appropriate function
|
||||
function_args_copy = function_args.copy() # Make a copy to avoid modifying the original
|
||||
function_response = await function_map[function_name](agent_state, **function_args_copy)
|
||||
return ToolExecutionResult(
|
||||
status="success",
|
||||
func_return=function_response,
|
||||
)
|
||||
|
||||
async def send_message_to_agent_and_wait_for_reply(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
|
||||
augmented_message = (
|
||||
f"[Incoming message from agent with ID '{agent_state.id}' - to reply to this message, "
|
||||
f"make sure to use the 'send_message' at the end, and the system will notify the sender of your response] "
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
return str(await self._process_agent(agent_id=other_agent_id, message=augmented_message))
|
||||
|
||||
async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
|
||||
# 1) Build the prefixed system‐message
|
||||
prefixed = (
|
||||
f"[Incoming message from agent with ID '{agent_state.id}' - "
|
||||
f"to reply to this message, make sure to use the "
|
||||
f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
|
||||
|
||||
task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
|
||||
|
||||
return "Successfully sent message"
|
||||
|
||||
async def send_message_to_agents_matching_tags_async(
|
||||
self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str]
|
||||
) -> str:
|
||||
# Find matching agents
|
||||
matching_agents = await self.agent_manager.list_agents_matching_tags_async(
|
||||
actor=self.actor, match_all=match_all, match_some=match_some
|
||||
)
|
||||
if not matching_agents:
|
||||
return str([])
|
||||
|
||||
augmented_message = (
|
||||
"[Incoming message from external Letta agent - to reply to this message, "
|
||||
"make sure to use the 'send_message' at the end, and the system will notify "
|
||||
"the sender of your response] "
|
||||
f"{message}"
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return str(results)
|
||||
|
||||
async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]:
|
||||
from letta.agents.letta_agent import LettaAgent
|
||||
|
||||
try:
|
||||
letta_agent = LettaAgent(
|
||||
agent_id=agent_id,
|
||||
message_manager=self.message_manager,
|
||||
agent_manager=self.agent_manager,
|
||||
block_manager=self.block_manager,
|
||||
passage_manager=self.passage_manager,
|
||||
actor=self.actor,
|
||||
)
|
||||
|
||||
letta_response = await letta_agent.step([MessageCreate(role=MessageRole.system, content=[TextContent(text=message)])])
|
||||
messages = letta_response.messages
|
||||
|
||||
send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)]
|
||||
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"response": send_message_content if send_message_content else ["<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 don’t blow up context
|
||||
content = (
|
||||
shorten(item.get("content", "").strip(), width=600, placeholder=" …")
|
||||
if WEB_SEARCH_CLIP_CONTENT
|
||||
else item.get("content", "").strip()
|
||||
)
|
||||
score = item.get("score")
|
||||
if WEB_SEARCH_INCLUDE_SCORE:
|
||||
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n"
|
||||
else:
|
||||
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n"
|
||||
formatted_blocks.append(block)
|
||||
|
||||
return WEB_SEARCH_SEPARATOR.join(formatted_blocks)
|
||||
|
||||
43
letta/services/tool_executor/tool_executor_base.py
Normal file
43
letta/services/tool_executor/tool_executor_base.py
Normal 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."""
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
632
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
100
tests/data/lines_1_to_100.txt
Normal file
100
tests/data/lines_1_to_100.txt
Normal 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
412
tests/data/long_test.txt
Normal 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 University’s School of Politics, Economics and Global Affairs in Madrid, Spain.[18]
|
||||
|
||||
Early life and education
|
||||
Letta was born in Pisa, Tuscany, to Giorgio Letta, an Abruzzo-born professor of mathematics who taught probability theory at the University of Pisa, member of the Lincean Academy and of the National Academy of the Sciences, and Anna Banchi, born in Sassari and raised in Porto Torres of Tuscan and Sardinian origins.[19][20] Born into a numerous family, uncles on his father's side include the centre-right politician Gianni Letta, a close advisor of Silvio Berlusconi, and the archaeologist Cesare Letta, while one of his paternal aunts, Maria Teresa Letta, served as vice president of the Italian Red Cross;[19] a maternal great-uncle is the poet and playwright Gian Paolo Bazzoni.[20]
|
||||
|
||||
After spending part of his childhood in Strasbourg,[21] Letta completed his schooling in Italy at the liceo classico Galileo Galilei in Pisa.[22] He has a degree in political science, which he received from the University of Pisa and subsequently obtained a PhD at the Sant'Anna School of Advanced Studies, a Graduate School with university status.[23][n 1]
|
||||
|
||||
From 2001 to 2003, Letta was professor at the University Carlo Cattaneo near Varese, and then he taught at the Sant'Anna School in Pisa in 2003 and at the HEC Paris in 2004.[25]
|
||||
|
||||
Political career
|
||||
|
||||
Letta in 2001
|
||||
This article is part of
|
||||
a series about
|
||||
Enrico Letta
|
||||
Political positions
|
||||
Minister for the Community Policies (1998–99)
|
||||
Minister of Industry (1999–2001)
|
||||
Prime Minister of Italy (2013–14)
|
||||
Democratic Party Secretary (2021–present)
|
||||
Political career
|
||||
2007 leadership electionLettiani360 Association
|
||||
Prime Minister of Italy
|
||||
2013 electionLetta CabinetGrand coalitionEuropean debt crisisMigrant crisis2013 Lampedusa shipwreckOperation Mare NostrumResignation
|
||||
Secretary of the Democratic Party
|
||||
Leadership2021 by-election2022 presidential election2022 government crisis2022 general election
|
||||
Academic career
|
||||
Sciences PoJacques Delors Institute
|
||||
|
||||
vte
|
||||
Letta, a Catholic,[26] began his political career in the Christian Democracy (DC),[27] the dominant centrist and Roman Catholic party, which ruled Italy for almost fifty years. From 1991 to 1995, Letta was president of the Youth of the European People's Party,[23] the official youth wing of the European People's Party, a European political party founded by national-level Christian democratic parties, including the Italian DC; he used his presidency to help strengthen long-term connections among a variety of centrist parties in Europe, and has since remained a convinced supporter of the European Union and European integration.[28][29]
|
||||
|
||||
During the Ciampi Cabinet headed by Carlo Azeglio Ciampi in 1993 and 1994, Letta worked as chief of staff for the minister of foreign affairs, Beniamino Andreatta; Andreatta, a left-leaning Christian Democrat economist with whom Letta had already been collaborating in a think tank known as Agenzia di Ricerche e Legislazione (AREL), played a highly influential role in Letta's political career.[23][28]
|
||||
|
||||
Following the collapse of the DC in 1994, Letta joined its immediate successor, the Italian People's Party (PPI); after serving as secretary general of the Euro Committee within the Ministry of Treasury from 1996 to 1997, he became deputy secretary of the party in 1997 and 1998, when it was fully allied with the centre-left.[30] In 1998, after the fall of Romano Prodi's first government, Letta was appointed Minister for the Community Policies in cabinet of Massimo D'Alema at the age of 32, becoming the youngest cabinet minister in post-war Italy.[27]
|
||||
|
||||
In 1999, Letta became Minister of Industry, Commerce and Crafts in the second government of D'Alema; a position that he held until 2001, serving also in the cabinet of Giuliano Amato.[31] During Amato's government he held the role of Minister of Foreign Trade too.[32]
|
||||
|
||||
In the 2001 Italian general election, Letta was elected to the Chamber of Deputies as a member of Democracy is Freedom – The Daisy, a newly formed centrist formation to which the Italian People's Party had joined.[30][33] In the following year, he was appointed national responsible for the economic policies of The Daisy.[34]
|
||||
|
||||
In 2004, Letta was elected member of the European Parliament, with nearly 179,000 votes, within The Olive Tree list,[35] joining the Alliance of Liberals and Democrats for Europe (ALDE) group. As MEP he became a member of the Committee on Economic and Monetary Affairs.[36] Letta served also in the committee for relations with the Maghreb countries and the Arab Maghreb Union.[37]
|
||||
|
||||
In 2006, Letta was re-elected to the Chamber of Deputies and was appointed Secretary of the Council of Ministers in the second government of Romano Prodi, thereby succeeding his uncle Gianni Letta who had held the same position in the outgoing cabinet of Silvio Berlusconi. In this post, he became the closest advisor of Prime Minister Prodi, becoming one of the most influential politicians within the government. However, Prodi's government fell after only two years following tensions within its majority caused by the resignation of the Minister of Justice, Clemente Mastella.[38][39] Following the 2008 Italian general election, which saw a victory of the centre-right, Letta returned the post to his uncle, when the Berlusconi IV Cabinet was sworn in.[28][29]
|
||||
|
||||
Leadership election candidacy
|
||||
Main article: 2007 Democratic Party (Italy) leadership election
|
||||
In 2007, together with other The Daisy's members, Letta joined the Democratic Party (PD), the new centre-left party, born from the union between The Daisy and the Democrats of the Left.[40][41] Having been a founding member of the party, Letta run in the first leadership election, which was held as an open primary. He announced his candidacy in July 2007 through a YouTube video.[42] A few weeks after the announcement, he compared the PD to Wikipedia, stating: "As in Wikipedia, even in the PD each of the hundreds of thousands of members must bring their own contributions, their own skills, which in certain fields are certainly more important than mine and those of the other leaders of the centre-left."[43] In support of his candidacy, Letta founded the 360 Association, a centrist and Christian leftist group, mainly composed by former members of The Daisy.[44][45]
|
||||
|
||||
Letta's candidacy was supported by prominent members of the Italian centre-left, like Francesco Cossiga, Paolo De Castro, Gianni Pittella, Vito De Filippo and many other former members of The Daisy.[46] Moreover, Letta's faction was composed by politicians considered close to Prime Minister Romano Prodi, a Christian leftist professor and founding father of the Italian centre-left.[47][48] However, Letta had to face the politician who, more than any other, had worked to the formation of the Democratic Party and who was unanimously considered the future leader of the centre-left, Walter Veltroni, the incumbent Mayor of Rome.[49] In the primary election, Veltroni won by a landslide with 75.8% of votes, followed by the former Minister of Health Rosy Bindi with 12.9% and Letta with 11.0%.[50]
|
||||
|
||||
After the primary election, Veltroni appointed Letta as the national responsible for labour. In May 2008, after the defeat in the 2008 election, Letta was appointed Shadow Minister of Labour and Social Policies in the second and last Shadow Cabinet formed in Italy.[51]
|
||||
|
||||
Deputy Secretary of the Democratic Party
|
||||
|
||||
Letta during a convention of his 360 Association in 2012
|
||||
During the leadership election of 2009, Letta supported the eventual winner, the social-democrat Pier Luigi Bersani, being appointed Deputy Secretary by the party's national convention.[52]
|
||||
|
||||
In June 2010, Letta organized a three-day meeting in Verona, during which he met, within its association, entrepreneurs and key leaders of Lega Nord, the largest party in Veneto and eastern Lombardy.[53][54] An opinion poll among northern Democrats, released during the "Nord Camp", showed that they were keener on an alliance with Lega Nord than Berlusconi's The People of Freedom.[55] Letta was praised both by Roberto Maroni and Umberto Bossi.[56]
|
||||
|
||||
In the 2013 Italian general election, the centre-left alliance Italy Common Good led by Bersani won a clear majority of seats in the Chamber of Deputies, thanks to a majority bonus that has effectively trebled the number of seats assigned to the winning party, while in the popular vote, it narrowly defeated the centre-right alliance of former prime minister Berlusconi. Close behind, the new anti-establishment Five Star Movement of comedian Beppe Grillo became the third-strongest force, clearly ahead of the centrist coalition of outgoing Prime Minister Mario Monti. In the Senate, no political group or party won an outright majority, resulting in a hung parliament.[57][58]
|
||||
|
||||
On 20 April 2013, when Bersani resigned as Secretary after the candidates for President of the Republic Franco Marini and Romano Prodi were defeated in the presidential election, the whole leadership of the PD, including Deputy Secretary Letta, resigned their positions.
|
||||
|
||||
Prime Minister of Italy
|
||||
Main article: Letta Cabinet
|
||||
Government formation
|
||||
Following five inconclusive ballots for the 2013 Italian presidential election, incumbent president Giorgio Napolitano accepted to be re-elected at the Quirinal Palace.[59] Eventually, Napolitano reluctantly agreed to serve for another term in order to safeguard the continuity of the country's institutions.[60][61] Napolitano was easily re-elected on 20 April 2013, receiving 738 of the 1007 possible votes, and was sworn in on 22 April 2013 after a speech when he asked for constitutional and electoral reforms.[62]
|
||||
|
||||
|
||||
Letta with President Giorgio Napolitano in Rome, 2013
|
||||
After his re-election, Napolitano immediately began consultations with the chairmen of the Chamber of Deputies, Senate and political forces, after the failure of the previous attempt with Bersani, and the establishment of a panel of experts by the President himself (dubbed as wise men by the press), in order to outline priorities and formulate an agenda to deal with the persistent economic hardship and growing unemployment. On 24 April 2013, Enrico Letta was invited to form a government by President Napolitano, following weeks of political deadlock.[63]
|
||||
|
||||
On 27 April, Letta formally accepted the task of leading a grand coalition government, with support from the centre-left Democratic Party, the centre-right People of Freedom (PdL) of Silvio Berlusconi and the centrist Civic Choice of outgoing PM Mario Monti. The government he formed became the first in the history of the Italian Republic to include representatives of all the major coalitions that had run in the latest election. His close relationship with his uncle, Gianni Letta, one of Berlusconi's most trusted advisors, was perceived as a way of overcoming the bitter hostility between the two opposing factions.[21][64] Letta appointed Angelino Alfano, secretary of the People of Freedom, as his Deputy Prime Minister. The new government was formally sworn-in as on 28 April.[65] During the swearing ceremony, a man fired gunshots outside Chigi Palace and wounded two Carabinieri.[66] The attacker, Luigi Preiti, was stopped and arrested; he declared that he wanted to kill politicians or at least to hit a "symbol of politics" and that he was forced by despair being unemployed and recently divorced.[67]
|
||||
|
||||
On 29 April, Letta's government won the confidence vote in the Chamber with 453 votes in favour, 152 against and 17 abstentions.[68] On the following day, he won the confidence vote in Senate too, with 233 votes in favour, 59 against 18 abstentions.[69] In his first speech in front of the Parliament, Letta stressed "necessity to restore decency, sobriety and a sense of honour"; he also advocated for a reduction of politics' costs.[70]
|
||||
|
||||
Economic policies
|
||||
|
||||
Prime Minister Letta in 2013
|
||||
During his premiership, Letta had to face a serious socio-economic crisis caused by the Great Recession and the subsequent European debt crisis. In 2013, one of the major problems of the country was the huge youth unemployment, which was valued around 40%.[71] To face this issue, on 14 June 2013, Letta scheduled a summit at Chigi Palace with the ministers of the economy, finance and labour of Italy, Germany, France and Spain, to agree on common EU policies for reducing unemployment.[8] After a few weeks, during a press conference at the conclusion of the Council of the European Union in Brussels, Letta announced that Italy would receive 1.5 billion euros in EU funds to fight youth unemployment.[9]
|
||||
|
||||
On 31 May, the Council of Ministers resolved to sponsor a bill to abolish party subsidies, which was widely considered a revolution in Italian politics and political parties, which heavily depended on public funds.[7] On 4 June, Letta, within his Minister of Economic Development, Flavio Zanonato and his Minister of the Environment, Andrea Orlando, announced the receivership of Ilva, one of the largest steel makers in Europe, for a duration of 36 months, appointing Enrico Bondi as receiver.[72]
|
||||
|
||||
On 15 June, the government approved the so-called "Action Decree" on hiring policies enabling economic recovery.[73] The decree was later approved by the Parliament between July and August 2013 with a confidence vote. The reform was harshly criticized by the anti-establishment Five Star Movement.[74] On 29 August, the government abolished IMU, the Italian tax on real estate introduced by the technocratic government of Mario Monti, for primary homes and for farm buildings .[75]
|
||||
|
||||
Immigration policies
|
||||
See also: Operation Mare Nostrum
|
||||
As a result of the Libyan and Syrian Civil Wars, a major problem faced by Letta upon becoming prime minister in 2013 was the high levels of illegal immigration to Italy.[76]
|
||||
|
||||
On 3 October 2013, a boat carrying migrants from Libya to Italy sank off the Italian island of Lampedusa. It was reported that the boat had sailed from Misrata, Libya, but that many of the migrants were originally from Eritrea, Somalia and Ghana.[77][78][79] An emergency response involving the Italian Coast Guard resulted in the rescue of 155 survivors.[78] On 12 October it was reported that the confirmed death toll after searching the boat was 359, but that further bodies were still missing;[80] a figure of "more than 360" deaths was later reported, becoming the deadliest shipwreck occurred in the Mediterranean Sea.[81]
|
||||
|
||||
After the Lampedusa tragedy, Prime Minister Letta decided to strengthen the national patrolling of Sicilian channel by authorizing Operation Mare Nostrum, a military and humanitarian operation whose purpose was to patrol the maritime border and provide relief to migrants. This operation had two main purposes: to safeguard life at sea and to combat the illegal smuggling of migrants.[82] The operation brought at least 150,000 migrants to Europe, mainly from Africa and the Middle East.[83] The operation ended a few months after the end of his premiership, on 31 October 2014.[84]
|
||||
|
||||
Foreign policies
|
||||
|
||||
Letta with the U.S. President Barack Obama in the Oval Office
|
||||
A strong pro-Europeanist politician, Letta built up close relations with other prominent European leaders like Angela Merkel, who was the first foreign leader he met, just a few days after his sworn in, on 30 April.[85] Letta also built a warm relationship with the French President François Hollande, with whom he shared a common view on austerity policies, considered outdated to face the economic crisis; Letta and Hollande often stressed the necessity to increase the public expenditures in investments.[86]
|
||||
|
||||
On 17 and 18 June, Letta participated in his first G8 summit at Lough Erne in Northern Ireland.[87] During the summit, Letta had his first bilateral meeting with the President of the United States, Barack Obama. On 17 October, Letta was invited to the White House by President Obama, who stated that he had been really impressed by the Italian Prime Minister and his reforms plan.[88]
|
||||
|
||||
On 5 and 6 September, Letta took part in the G20 summit in Saint Petersburg. The summit was focused on the aftermath of the Syrian civil war. Letta advocated for a diplomatic resolution of the crisis promoted by the United Nations.[89] On 25 September, during his speech in front of the United Nations General Assembly, Letta asked a deep reform of the UN Security Council.[90]
|
||||
|
||||
September 2013 government crisis
|
||||
On 28 September 2013, five ministers of The People of Freedom resigned on the orders of their leader, Silvio Berlusconi, pointing to the decision to postpone the decree that prevented the increase of the VAT from 21 to 22%, thus opening a government crisis.[91] On the following day, Letta had a meeting with President Napolitano to discuss the possible alternatives to solve the crisis. The head of State stressed that he would dissolve parliament only if there were no other possible alternatives.[92]
|
||||
|
||||
|
||||
Letta with Angelino Alfano and Giorgio Napolitano in December 2013
|
||||
In the following days, dozens of members of PdL prepared to defy Berlusconi and vote in favour of the government, prompting him to announce that he would back the Prime Minister.[93][94][95] On 2 October, the government received 235 votes in favor and 70 against in the Senate, and 435 in favor and 162 against in the Chamber of Deputies.[96][97] Letta could thus continue his grand coalition government.[98]
|
||||
|
||||
On 23 November, the Senate had to vote about the expulsion of Berlusconi from the Parliament, due to a conviction of tax fraud by the court of final instance and the Court of Cassation, which occurred a few months before.[99] Because he had been sentenced to a gross imprisonment for more than two years, the Senate voted to expel him from the Parliament, barring him from serving in any legislative office for six years.[100][101]
|
||||
|
||||
After his expulsion from the Parliament, Berlusconi, who disbanded the PdL a few days before re-founding Forza Italia party, withdrew his support to the government. However, the interior minister Angelino Alfano did not follow his former leader, founding, along with other ministers and many members of the parliament, the New Centre-Right party, remaining in government.[102] The government later won key confidence votes in December 2013, with 173 votes in favour in the Senate and 350 in the Chamber.[103]
|
||||
|
||||
On 26 January 2014, the Minister of Agriculture, Nunzia De Girolamo, resigned from her post due to claims of improper conduct linked to a scandal in the local healthcare system of her hometown, Benevento.[104][105] Her resignation was accepted by Letta on the following day, who took the ministerial role ad interim.[106]
|
||||
|
||||
Resignation
|
||||
On 8 December 2013, the Mayor of Florence, Matteo Renzi, won the Democratic Party leadership election by a landslide, immediately starting rumours about the possibility of becoming the new prime minister.[107] On 17 January 2014, while on air at Le invasioni barbariche on La7 TV channel, interviewed about tensions between him and Prime Minister Letta, Renzi tweeted the hashtag #enricostaisereno ("Enrico don't worry") to reassure his party colleague that he was not plotting anything against him.[108]
|
||||
|
||||
|
||||
Letta with Matteo Renzi and President Napolitano in October 2013
|
||||
The growing criticism of the slow pace of Italian economic reform left Letta increasingly isolated within his own party.[109] At a PD's meeting on 13 February 2014, the Democratic Party leadership voted heavily in favour of Renzi's motion for "a new government, a new phase and a radical programme of reforms". Minutes after the party backed Renzi's proposal by 136 votes to 16, with two abstentions, Letta went to the Quirinal Palace, for a bilateral meeting with President Napolitano.[11]
|
||||
|
||||
In an earlier speech, Renzi had paid tribute to Letta, saying that he did not intend to put him "on trial". But, without directly proposing himself as the next prime minister, he said the Eurozone's third-largest economy urgently needed "a new phase" and "radical programme" to push through badly-needed reforms. The motion he put forward made clear "the necessity and urgency of opening a new phase with a new executive". Speaking privately to party leaders, Renzi said that Italy was "at a crossroads" and faced either holding fresh elections or a new government without a return to the polls.[110]
|
||||
|
||||
On 14 February, Letta resigned from the office of prime minister.[111] Following Letta's resignation, Renzi received the task of forming a new government from President Napolitano on 17 February,[112] and was formally sworn in as prime minister on 22 February.[113]
|
||||
|
||||
Academic career
|
||||
|
||||
Letta speaking at the Jacques Delors Institute in 2016
|
||||
In 2015, Letta resigned as a member of the Chamber of Deputies, after having voted against the new electoral law proposed by Prime Minister Renzi; at the same time, he announced that he would not renew the PD's membership.[114]
|
||||
|
||||
In April 2015, Letta moved to Paris to teach at the Sciences Po, a higher education institute of political science. Since 1 September, he became dean of the Paris School of International Affairs (PSIA) of the same institute.[115] Along with his commitment to Sciences Po, he also had teaching periods at the University of Technology Sydney and the School of Global Policy and Strategy at the University of California, San Diego. In the same year, Letta launched Scuola di Politiche (School of Politics), a course of political science for young Italians.[116]
|
||||
|
||||
In 2016, Letta supported the constitutional reform proposed by Renzi to reduce the powers of the Senate.[117] In the same year, along with the Jacques Delors Institute, he launched a school of political science focused on European issues, known as Académie Notre Europe.[118] In October 2017, he joined the new Comitè Action Publique 2022, a public commission for the reform of state and public administration in France which was strongly supported by President Emmanuel Macron.[119]
|
||||
|
||||
|
||||
Letta with François Hollande and Jean-Claude Juncker in 2016
|
||||
In March 2019, following the victory of Nicola Zingaretti in the PD leadership election, Letta announced that he would re-join the party after four years.[120] In the same year, Letta also served on the advisory board of the annual Human Development Report of the United Nations Development Programme (UNDP), co-chaired by Thomas Piketty and Tharman Shanmugaratnam.[121] In 2020, he spoke in favour of the constitutional reform to reduce the number of MPs, considering it the first step to overcome perfect bicameralism.[122]
|
||||
|
||||
Following his retirement from politics, Letta became advisor of many corporations and international organizations like Abertis, where he became member of the Board of Directors in 2016,[123][124] Amundi, in which he served as member of the Global Advisory Board since 2016,[125] the Eurasia Group, of which he has been Senior Advisor since 2016,[126] Publicis, where he served within the International Advisory Board since 2019[127] and Tikehau Capital, of which he became member of the International Advisory Board.[128]
|
||||
|
||||
Letta is a member of many no-profit organizations like the International Gender Champions (IGC),[129] the British Council, Re-Imagine Europa,[130] the Trilateral Commission, in which he presided the European Group,[131] the Aspen Institute Italia, in which he served in the Executive Committee,[132] Associazione Italia ASEAN, of which he became chairman[133] and the Institut de Prospective Economique du Monde Méditerranéen (IPEMED).[134].
|
||||
|
||||
Letta was appointed Dean of IE School of Politics, Economics and Global Affairs. Letta will replace Manuel Muñiz, the current Provost of IE University and Charmain of the Board of IE New York College. He will join IE University on November 20.[135]
|
||||
|
||||
Secretary of the Democratic Party
|
||||
|
||||
Letta speaking at the European Parliament during the memorial for David Sassoli, in January 2022
|
||||
In January 2021, after the government crisis which forced Prime Minister Giuseppe Conte to resign, a national unity government led by Mario Draghi was formed.[136] In the midst of the formation of Draghi's government, Zingaretti was heavily criticized by the party's minority for his management of the crisis and strenuous support to Conte. On 4 March, after weeks of internal turmoil, Zingaretti announced his resignation as secretary, stating that he was "ashamed of the power struggles" within the party.[137]
|
||||
|
||||
In the next days, many prominent members of the PD, including Zingaretti himself, but also former prime minister Paolo Gentiloni, former party secretary Dario Franceschini and President of Emilia-Romagna Stefano Bonaccini, publicly asked former Letta to become the new leader of the party.[138][139] Following an initial reluctance, Letta stated that he needed a few days to evaluate the option.[140] On 12 March, he officially accepted his candidacy as new party's leader.[141][142] On 14 March, the national assembly of the PD elected Letta secretary with 860 votes in favour, 2 against and 4 abstentions.[143][144]
|
||||
|
||||
On 17 March, Letta appointed Peppe Provenzano and Irene Tinagli as his deputy secretaries.[145] On the following day, he appointed the party's new executive, composed of eight men and eight women.[146] Later that month, Letta forced the two Democratic leaders in Parliament, Graziano Delrio and Andrea Marcucci, to resign and proposed the election of two female leaders.[147] On 25 and 30 March, senators and deputies elected Simona Malpezzi and Debora Serracchiani as their leaders in the Senate and in the Chamber.[148][149]
|
||||
|
||||
|
||||
Letta with Giuseppe Conte and the Finnish PM Sanna Marin in 2022
|
||||
In July 2021, Letta announced his intention to run for the Chamber of Deputies in the Siena constituency, which remained vacant after the resignation of Pier Carlo Padoan. On 4 October, Letta won the by-election with 49.9% of votes, returning to the Parliament after six years.[150] In the concurrent local elections, the PD and its allies won municipal elections in Milan, Bologna, Naples, Rome, Turin and many other major cities across the country.[151]
|
||||
|
||||
As leader of the third political force in the parliament, Letta played an important role in the re-election of incumbent president Sergio Mattarella. On 23 January 2022, during Fabio Fazio's talk show Che tempo che fa, Letta stated that his favourable candidates for the presidency were Mario Draghi and Sergio Mattarella.[152] On the morning of 29 January, after the fall of all other possible candidacies, Letta asked the other leaders to follow "the Parliament's wisdom", referring to the massive support that Mattarella had received in the previous ballots.[153] On the same day, all the main parties asked Mattarella to serve for a second term. Despite his initial firm denial, Mattarella accepted the nomination[154] and was re-elected with 759 votes.[155]
|
||||
|
||||
In July 2022, tensions arose within the governing majority, especially between Giuseppe Conte, leader of the Five Star Movement, and Prime Minister Draghi. Letta, who was trying to form a broad centre-left coalition with the M5S in the following election, was particularly critical of the possibility of a government crisis.[156] On 13 July, Conte announced that the M5S would revoke its support to the national unity government regarding the so-called decreto aiuti (English: aid decree), concerning economic stimulus to contrast the ongoing energy crisis, opening a political crisis within the majority.[157] On the following day, the M5S abstained and Prime Minister Draghi, despite having won the confidence vote, resigned.[158] However, the resignation was rejected by President Mattarella.[159] On the same day, Letta stressed that a government crisis needed to be officially opened in the Parliament, adding that "Italy deserved to stand with a strong personality like that of PM Draghi and the team that was around him."[160] However, on 21 July, Draghi resigned again after a new confidence vote in the Senate failed to pass with an absolute majority, following the defections of M5S, Lega, and Forza Italia;[161][162] A snap election was called for 25 September 2022.[163]
|
||||
|
||||
After the 2022 general election, Enrico Letta conceded defeat and announced that he would not stand at the congress to elect the new party secretary.[164][165][166][167] He was succeeded by Elly Schlein, following the election on 26 February 2023.[168]
|
||||
|
||||
Personal life
|
||||
Letta is married to Gianna Fregonara, an Italian journalist, with whom he had three children, Giacomo, Lorenzo and Francesco.[169]
|
||||
|
||||
Letta is known to be fond of listening to Dire Straits and playing Subbuteo;[170] he is also an avid supporter of A.C. Milan.[171] In addition to his native Italian, Letta speaks French and English fluently.[29]
|
||||
|
||||
Electoral history
|
||||
Election House Constituency Party Votes Result
|
||||
2001 Chamber of Deputies Piedmont 1 DL –[a] check Elected
|
||||
2004 European Parliament North-East Italy Ulivo 178,707 check Elected
|
||||
2006 Chamber of Deputies Lombardy 1 Ulivo –[a] check Elected
|
||||
2008 Chamber of Deputies Lombardy 2 PD –[a] check Elected
|
||||
2013 Chamber of Deputies Marche PD –[a] check Elected
|
||||
2021 Chamber of Deputies Siena PD 33,391 check Elected
|
||||
2022 Chamber of Deputies Lombardy 1 PD –[a] check Elected
|
||||
Elected in a closed list proportional representation system.
|
||||
First-past-the-post elections
|
||||
2021 Italian by-election (C): Siena
|
||||
Candidate Party Votes %
|
||||
Enrico Letta Centre-left coalition 33,391 49.9
|
||||
Tommaso Marrocchesi Marzi Centre-right coalition 25,303 37.8
|
||||
Others 8,191 12.3
|
||||
Total 66,885 100.0
|
||||
References
|
||||
Quirinale, il governo di Letta giura davanti a Napolitano, Il Fatto Quotidiano
|
||||
Letta eletto segretario: "Serve un nuovo Pd aperto, non partito del potere", Sky Tg24
|
||||
Enrico Letta, Enciclopedia Treccani
|
||||
Italian Parliament Website LETTA Enrico – PD Retrieved 24 April 2013
|
||||
Nuovo governo, incarico a Enrico Letta. Napolitano: "I media cooperino", Il Fatto Quotidiano
|
||||
"Letta: Grande coalizione, bisogna farsene una ragione". Archived from the original on 8 October 2016. Retrieved 28 January 2019.
|
||||
Tre canali di finanziamento, più trasparenza. Ecco punto per punto il ddl del governo, Corriere della Sera
|
||||
Vertice lavoro, Letta ai ministri europei: «Non c'è più tempo, si deve agire subito Scelta sciagurata guardare solo i conti» – Il Messaggero Archived 16 June 2013 at the Wayback Machine. Ilmessaggero.it. Retrieved on 24 August 2013.
|
||||
Letta: all'Italia 1,5 miliardi per il lavoro. Grillo «poteva mandare tutto in vacca», Corriere della Sera
|
||||
Letta: perché difendo Mare Nostrum, Avvenire
|
||||
"Letta al Quirinale, si è dimesso – Top News". Retrieved 12 July 2016.
|
||||
Enrico Letta, Sciences Po
|
||||
Pd, Zingaretti si dimette. Dice addio il decimo segretario in 14 anni, Il Sole 24 Ore
|
||||
Letta, il giorno della scelta. Zingaretti: rilancerà il Pd, il manifesto
|
||||
Letta: "Non vi serve un nuovo segretario, ma un nuovo Pd", Huffington Post
|
||||
Elezioni suppletive Siena: vince Letta, La Stampa
|
||||
https://www.ansa.it/sito/notizie/politica/2024/12/20/in-aula-alla-camera-si-votano-le-dimissioni-di-enrico-letta_7a395834-ba1c-4567-bcfe-b3e3f499f045.html
|
||||
Popova, Maria (1 October 2024). "Enrico Letta, new dean of the Faculty of Politics and Economics of the IE University of Segovia". Top Buzz Times. Retrieved 2 October 2024.
|
||||
Motta, Nino (2 February 2013). "Un Letta per ogni stagione". Il Centro.
|
||||
"Gli zii di Enrico Letta. Non solo Gianni: c'è anche Gian Paolo Bazzoni a Porto Torres". Sardinia Post. 25 April 2013. Retrieved 2 June 2013.
|
||||
Winfield, Nicole (24 April 2013). "Enrico Letta Appointed Italian Prime Minister, Asked To Form Government". The Huffington Post. Retrieved 4 May 2013.
|
||||
Letta, Enrico (2013). "Curriculum Vitae" (PDF). Archived from the original (PDF) on 11 June 2013. Retrieved 3 June 2013.
|
||||
"Enrico Letta: la bio del giovane dalla grande esperienza". Huffington Post (in Italian). 24 August 2013. Retrieved 3 June 2013.
|
||||
"Su esecutivo marchio scuola Sant'Anna: Pisa Letta si e' specializzato, Carrozza e' stato rettore" (in Italian). ANSA. 27 April 2013. Retrieved 11 June 2013.
|
||||
Governo. Enrico Letta, l’allievo di Andreatta diventa presidente del Consiglio, Il Giornale
|
||||
Enrico Marro (24 April 2013). "Chi è Enrico Letta? Quel giovane cattolico moderato, con agganci in tutto il Transatlantico. Nipote di Gianni. E fan di Mandela". Il Sole-24 Ore (in Italian). Milan. Retrieved 6 December 2022.
|
||||
"Profile: Enrico Letta". BBC News. 24 April 2013. Retrieved 3 June 2013.
|
||||
Povoledo, Elisabetta (28 April 2013). "An Italian Leader and a Political Acrobat". The New York Times. Retrieved 3 June 2013.
|
||||
Dinmore, Guy (24 April 2013). "Italy's Enrico Letta a party loyalist and bridge-builder". Financial Times.
|
||||
Sachelli, Orlando (24 April 2013). "Enrico Letta, il giovane Dc che deve far da paciere tra Pd e Pdl". Il Giornale (in Italian). Retrieved 7 June 2013.
|
||||
Enrico Letta, Il Sole 24 Ore
|
||||
DDL presentati dal Ministero del commercio con l'estero (Governo Amato-II), Parlamento
|
||||
"Pisano, milanista, baby-ministro. Ecco chi è Enrico Letta, l'eterno "giovane" del Pd". Libero (in Italian). 24 April 2013.
|
||||
Enrico Letta, Biografie Online
|
||||
Elezioni Europee del 2004. Circoscrizione Italia Nord-Orientale, Dipartimento per gli Affari Interni
|
||||
"European Parliament Website". European Parliament. Retrieved 7 May 2013.
|
||||
Members of the European Parliament, European Parliament
|
||||
"Mastella to Drop Support for Prodi, Favors Elections", Bloomberg, 21 January 2008.
|
||||
"Italy PM in cabinet crisis talks", BBC News, 21 January 2008.
|
||||
Vespa, Bruno (2010). Il Cuore e la Spada: Storia politica e romantica dell'Italia unita, 1861–2011. Mondadori. p. 650. ISBN 9788852017285.
|
||||
Augusto, Giuliano (8 December 2013), "De profundis per il Pd", Rinascita, archived from the original on 1 March 2014
|
||||
Pd: Letta: «Mi candido». Video sul web, Corriere della Sera
|
||||
Letta leader? Senza grinta, Il Fatto Quotidiano
|
||||
"Associazione 360". Archived from the original on 20 June 2008. Retrieved 3 July 2008.
|
||||
"Noi". Associazione Trecento Sessanta. Archived from the original on 14 March 2010. Retrieved 10 March 2010.
|
||||
De Castro: "Candidatura di Letta grande occasione per coinvolgere la società Archived 27 September 2007 at the Wayback Machine, Enrico Letta
|
||||
"DemocraticiPerLetta.info – Home". Archived from the original on 6 October 2008. Retrieved 3 July 2008.
|
||||
"cantieredemocratico.it – - Notizie: Pd: per Veltroni tre liste nazionali". Archived from the original on 7 January 2009. Retrieved 3 July 2008.
|
||||
"Rome Mayor Set to Win Left's Leadership". Associated Press. 14 October 2007. Retrieved 15 October 2007.[permanent dead link]
|
||||
"Veltroni stravince con il 76% ma è la festa dei cittadini elettori". la Repubblica (in Italian). 14 October 2007.
|
||||
Partito Democratico Archived 2008-04-30 at the Wayback Machine
|
||||
"Pd, Bersani indica la rotta "Noi, partito dell'alternativa"". Quotidiano.net (in Italian). 9 September 2009. Retrieved 26 April 2013.
|
||||
"Il Pd "sale" al Nord. E dialoga con Maroni". Archiviostorico.corriere.it. Retrieved 17 July 2014.
|
||||
"Letta accoglie Maroni "Con la Lega si deve parlare"". Archiviostorico.corriere.it. Retrieved 17 July 2014.
|
||||
"L' elettore del Nord: il Pdl? Meglio allearsi con la Lega". Archiviostorico.corriere.it. Retrieved 17 July 2014.
|
||||
"Bossi loda Letta: giusto il dialogo sa che con noi si vince alle urne". Archiviostorico.corriere.it. Retrieved 17 July 2014.
|
||||
"Italian election results: gridlock likely – as it happened". The Guardian. 26 February 2013. Retrieved 27 February 2013.
|
||||
"Italy struggles with 'nightmare' election result". BBC News. 26 February 2013. Retrieved 27 February 2013.
|
||||
"Italy crisis: President Giorgio Napolitano re-elected". BBC News. 20 April 2013. Retrieved 20 April 2013.
|
||||
Mackenzie, James (20 April 2013). "Giorgio Napolitano, Italy's reluctant president". Bloomberg L.P. Retrieved 21 April 2013.
|
||||
Napolitano, Giorgio; Scalfari, Eugenio (9 June 2013). "Napolitano si racconta a Scalfari: 'La mia vita, da comunista a Presidente'" (Video, at 59 min). La Repubblica (in Italian). Retrieved 9 June 2013.
|
||||
The critical findings on electoral law echoed in the words that the head of state gave 22 April 2013 before the Electoral College that had re-elected him for a second term: Buonomo, Giampiero (2013). "Porcellum, premio di maggioranza a rischio". Golem Informazione. Archived from the original on 11 December 2019. Retrieved 11 March 2021.
|
||||
Frye, Andrew (24 April 2013). "Letta Named Italian Prime Minister as Impasse Ends". Bloomberg. Retrieved 26 April 2013.
|
||||
"Bridge-builder Enrico Letta seals Silvio Berlusconi deal". The Australian. 29 April 2013. Retrieved 8 June 2013.
|
||||
Nasce il governo Letta, ora la fiducia. Il premier: «Sobria soddisfazione», Corriere della Sera
|
||||
"New Italian 'grand coalition' government sworn in". BBC News. 28 April 2013. Retrieved 28 April 2013.
|
||||
Sparatoria Palazzo Chigi: due carabinieri feriti. L’attentatore: "Puntavo ai politici", Il Fatto Quotidiano
|
||||
Letta: «Abbiamo un'ultima possibilità. Basta debiti scaricati sulle future generazioni», Corriere della Sera
|
||||
Governo Letta, fiducia anche al Senato, Corriere della Sera
|
||||
Governo Letta, fiducia alla Camera: 453 sì, 153 no. Si astiene la Lega, Il Fatto Quotidiano
|
||||
Disoccupazione giovanile, Bce: "Nel 2013 in Italia è arrivata vicina al 40%", Il Fatto Quotidiano
|
||||
Ilva, firmato il decreto: Enrico Bondi commissario per 36 mesi, Il Fatto Quotidiano
|
||||
Il Decreto del fare, misura per misura – Europa Quotidiano Archived 19 June 2013 at the Wayback Machine. Europaquotidiano.it (16 June 2013). Retrieved on 24 August 2013.
|
||||
La Camera approva il «decreto del fare», Corriere della Sera
|
||||
Abolizione IMU 2013, ecco cosa cambia per "prima casa", edilizia e terreni agricoli, EdilTecnico
|
||||
Letta da Malta: " Orgoglio per l'operazione Mare Nostrum", Rai News
|
||||
Pianigiani, Gaia (3 October 2013). "Scores of Migrants Dead After Boat Sinks Off Sicily". The New York Times. Siracusa. Retrieved 3 October 2013.
|
||||
"Dozens of migrants die in Italy boat sinking near Lampedusa". BBC News. 3 October 2013. Retrieved 3 October 2013.
|
||||
"Witness: Boat migrants used bottles to stay afloat". USA Today. 4 October 2013. Retrieved 4 October 2013.
|
||||
"Mediterranean 'a cemetery' – Maltese PM Muscat". BBC News. 12 October 2013. Retrieved 12 October 2013.
|
||||
"Lampedusa boat tragedy: Migrants 'raped and tortured'". BBC News. 8 November 2013. Retrieved 8 November 2013.
|
||||
"Mare Nostrum Operation". Ministry of Defence of Italy. Retrieved 16 April 2015.
|
||||
"IOM Applauds Italy's Life-Saving Mare Nostrum Operation: "Not a Migrant Pull Factor"". International Organization for Migration. 31 October 2014. Archived from the original on 16 April 2015. Retrieved 16 April 2015.
|
||||
Ella Ide (31 October 2014). "Italy ignores pleas, ends boat migrant rescue operation". Yahoo! News. Retrieved 16 April 2015.
|
||||
Letta, tour in Europa: vertice con Merkel. La cancelliera: «Italia sulla buona strada», Il Fatto Quotidiano
|
||||
Ue, asse Letta-Hollande per la crescita, Corriere della Sera
|
||||
G8, il debutto di Enrico Letta Prima l'incontro con Obama L'incognita Siria divide già – Quotidiano Net. Quotidiano.net. Retrieved on 24 August 2013.
|
||||
Usa, Obama riceve Letta: "Italia sulla strada giusta, impressionato da premier", la Repubblica
|
||||
Siria, Enrico Letta: "Una soluzione politica con l'Onu è ancora possibile. Strada stretta, ma fondamentale", Huffington Post
|
||||
Letta a Wall Street: "Siamo affidabili". E all'Onu chiede riforma Consiglio sicurezza, la Repubblica
|
||||
"Berlusconi fa dimettere ministri: è crisi. Letta: gesto folle per motivi personali". Repubblica.it. 28 September 2013. Retrieved 13 February 2014.
|
||||
"Napolitano: "Verifico possibilità legislatura". Caos nel Pdl. Alfano: "No a estremismi"". Repubblica.it. 29 September 2013. Retrieved 13 February 2014.
|
||||
Berlusconi U-turn secures Italian government survival
|
||||
"Italian PM wins confidence vote after Berlusconi abandons revolt - as it happens". The Guardian. 2 October 2013. Archived from the original on 27 March 2023.
|
||||
Italy crisis: PM Letta wins vote after Berlusconi U-turn
|
||||
"Irrevocabili dimissioni ministri Pdl – Politica". ANSA.it. 28 September 2013. Retrieved 13 February 2014.
|
||||
"Letta mercoledì a Camera e Senato – Politica". ANSA.it. 29 September 2013. Retrieved 13 February 2014.
|
||||
"Berlusconi si arrende, Letta ottiene fiducia Napolitano: "Ora basta giochi al massacro"". Repubblica.it. 16 November 2013. Retrieved 13 February 2014.
|
||||
Parks, Tim (24 August 2013). "Holding Italy Hostage". The New York Review of Books. Archived from the original on 25 October 2013. Retrieved 6 September 2013.
|
||||
Italy's Senate expels ex-PM Silvio Berlusconi, BBC, 27 November 2013. Archived 30 November 2013 at the Wayback Machine
|
||||
"Berlusconi vows to stay in politics as ban approaches". Reuters. 18 September 2013. Archived from the original on 14 October 2013. Retrieved 18 September 2013.
|
||||
james mackenzie (3 December 2013). "Italy PM Letta to seek new confidence vote on December 11". The Star. Malaysia. Archived from the original on 7 December 2013. Retrieved 13 February 2014.
|
||||
Letta incassa la fiducia, ma è bagarre in aula. E la Lega perde un pezzo, la Repubblica
|
||||
James MacKenzie (26 January 2014). "Italy minister resigns, adding to headaches for government". Reuters. Rome. Retrieved 29 January 2014.
|
||||
"Italy's agriculture minister resigns, blow to govt". Seattle Pi. 26 January 2014. Retrieved 29 January 2014.
|
||||
"Premier accepts agriculture minister's resignation". La Gazzetta del Mezzogiorno. 27 January 2014. Archived from the original on 2 July 2015. Retrieved 29 January 2014.
|
||||
Primarie PD 2013, Partito Democratico
|
||||
Renzi: quando assicurava di non voler prendere il posto di Letta, Corriere della Sera
|
||||
"Napolitano accepts Letta's resignation as Italian prime minister". Euronews. 14 February 2014. Archived from the original on 14 February 2014. Retrieved 14 February 2014.
|
||||
Lizzy Davies in Rome. "Italian PM Enrico Letta to resign". The Guardian. Retrieved 13 February 2014.
|
||||
Правительственный кризис в Италии: премьер Летта ушел в отставку (in Russian). RIA Novosti. 14 February 2014. Retrieved 14 February 2014.
|
||||
"39 Year Old Matteo Renzi becomes, at 39, Youngest Italian Prime Minister". IANS. news.biharprabha.com. Retrieved 17 February 2014.
|
||||
"Matteo Renzi sworn in as Italy's new PM in Rome ceremony". BBC. 22 February 2014. Retrieved 26 February 2014.
|
||||
Enrico Letta si dimette da deputato: il discorso in aula e il lungo applauso della Camera, Huffington Post
|
||||
"Enrico Letta, New Dean of PSIA". SciencesPo News. 21 April 2014. Retrieved 10 March 2017.
|
||||
"Scuola di Politiche", Scuoladipolitiche.eu. Retrieved 4 February 2022.
|
||||
Letta: «Italicum legge sbagliata. Ma al referendum io voterò Sì», Corriere della Sera
|
||||
Letta battezza Académie Notre Europe: "Per creare una classe dirigente europea ed europeista", Huffington Post
|
||||
Macron chiama Letta a far parte della Commissione per la riforma dello Stato, Il Giornale
|
||||
Enrico Letta: "Dopo 5 anni riprendo la tessera del Pd. Mai più partito dell’antipatia", la Repubblica
|
||||
2019 Human Development Report Advisory Board Members United Nations Development Programme (UNDP).
|
||||
Referendum, Letta: "Voterò Sì convintamente. Tutte le nostre proposte di riforma prevedevano lo stesso taglio. 630 deputati? Ne bastano 400", Il Fatto Quotidiano
|
||||
"Abertis' Board of Directors appoints Luis Fortuño and Enrico Letta as new directors". Abertis.com. Archived from the original on 16 August 2018. Retrieved 16 August 2018.
|
||||
Polizzi, Daniela. "Ai Benetton le autostrade spagnole Accordo Atlantia-Hochtief su Abertis". Corriere della Sera (in Italian). Retrieved 16 August 2018.
|
||||
Amundi creates a Global Advisory Board with world-renowned experts in global economic and political issues Amundi, press release of 31 May 2016.
|
||||
Former Italian Prime Minister Enrico Letta joins Eurasia Group as Senior Advisor Eurasia Group, press release of 8 March 2016.
|
||||
Supervisory Board Publicis, press release of 7 March 2019.
|
||||
International Advisory Board Archived 4 January 2021 at the Wayback Machine Tikehau Capital.
|
||||
Members International Gender Champions (IGC).
|
||||
Advisory Board Re-Imagine Europa.
|
||||
Membership Trilateral Commission.
|
||||
"Comitato Esecutivo". Aspen Institute Italia. Archived from the original on 9 October 2010. Retrieved 26 April 2013.
|
||||
About Us Archived 25 November 2018 at the Wayback Machine Associazione Italia ASEAN.
|
||||
Governance Institut de Prospective Economique du Monde Méditerranéen (IPEMED), Paris.
|
||||
https://www.ie.edu/school-politics-economics-global-affairs/news/enrico-letta-former-italian-prime-minister-appointed-dean-ie-school-politics-economics-global-affairs/
|
||||
"Mario Draghi sworn in as Italy's new prime minister". BBC News. 13 February 2021.
|
||||
"Zingaretti quits as chief of Italy's Democratic party over infighting". Financial Times. 4 March 2021. Archived from the original on 7 March 2021. Retrieved 12 March 2021.
|
||||
"Zingaretti: "Letta può rendere il Pd protagonista indiscusso della democrazia italiana"" (in Italian). Il Foglio. 12 March 2021.
|
||||
""Dobbiamo salvare il Pd". Così Franceschini lavora per Letta" (in Italian). Il Foglio. 9 March 2021.
|
||||
"Letta takes time to consider taking lead of PD – English". ANSA.it. 10 March 2021. Retrieved 10 March 2021.
|
||||
"Pd, Letta sarà il nuovo segretario. Il tweet: "Io ci sono, chiedo voto sulla base delle mie parole". Ecco il programma dell'Assemblea di domenica" (in Italian). La Repubblica. 12 March 2021.
|
||||
"Enrico Letta, Italian ex-PM, poised for political comeback". Politico Europe. 12 March 2021.
|
||||
Pd, Letta segretario con 860 sì: "Serve un nuovo Pd. Priorità a lavoro, giovani e donne". Promette battaglia sul voto ai sedicenni e Ius soli. E sulle alleanze: "Sentirò 5S e Renzi", la Repubblica
|
||||
First speech as candidate secretary of the Italian Partito Democratico (in Italian). Archived from the original on 13 December 2021.
|
||||
Provenzano e Tinagli, il cacciavite di Letta funziona, Huffington Post
|
||||
Pd, Letta nomina la nuova segreteria del partito: sedici membri, otto uomini e otto donne, la Repubblica
|
||||
Pd, Letta: "Nominiamo due donne capigruppo alla Camera e al Senato". Delrio: "Agito sempre per parità", la Repubblica
|
||||
Pd, Simona Malpezzi è la nuova capogruppo al Senato. E alla Camera vacilla l'ipotesi Serracchiani, la Repubblica
|
||||
Debora Serracchiani capogruppo Pd alla Camera, ANSA
|
||||
Letta vince a Siena le suppletive, ANSA
|
||||
Risultati ballottaggi del 17 e 18 ottobre. A Roma e Torino trionfa il centrosinistra. A Trieste vince il centrodestra, la Repubblica
|
||||
Quirinale, la proposta di Letta: "Draghi o Mattarella, il bis sarebbe il massimo", la Repubblica
|
||||
"L'assist di Letta la Mattarella-bis". Corriere della Sera (in Italian). 29 January 2022. Retrieved 29 January 2022.
|
||||
"Mattarella to be re-elected after saying he is 'willing'". ANSA. 29 January 2022. Archived from the original on 29 January 2022. Retrieved 30 January 2022.
|
||||
"Elezioni Presidente della repubblica 2022". La Repubblica (in Italian). 29 January 2022. Archived from the original on 29 January 2022. Retrieved 28 January 2022.
|
||||
Letta: "Evitiamo il colpo di pistola di Sarajevo, se cade il governo si va al voto", Huffington Post
|
||||
"Italy's government on the brink as 5-Star threatens to boycott confidence vote". Guardian. 13 July 2022. Retrieved 13 July 2022.
|
||||
Italian Prime Minister Mario Draghi says he’ll resign, government faces collapse, Washington Post
|
||||
Mattarella respinge dimissioni Draghi e manda premier a Camere, ANSA
|
||||
Governo in bilico, Letta: "La crisi si apre in Parlamento". Anche la Lega valuta la verifica di maggioranza: cosa significa, la Repubblica
|
||||
Horowitz, Jason (20 July 2022). "Draghi Government Falls Apart, Returning Turbulent Politics to Italy". The New York Times. ISSN 0362-4331. Archived from the original on 21 July 2022. Retrieved 21 July 2022.
|
||||
"Italy in limbo as Draghi wins confidence vote but loses parliamentary majority". France 24. Agence-France Press. 20 July 2022. Archived from the original on 20 July 2022. Retrieved 21 July 2022.
|
||||
Borghese, Livia; Braithwaite, Sharon; Fox, Kara; Latza Nadeau, Barbie; Ruotolo, Nicola (21 July 2022). "Italy's president dissolves parliament, triggering snap election following Draghi's resignation". CNN. Archived from the original on 21 July 2022. Retrieved 22 July 2022.
|
||||
"«Letta si dimette sotto questa percentuale». E i big già lo archiviano: è caccia al nuovo segretario". 6 September 2022.
|
||||
"Come una mucca nel corridoio. Il congresso Pd si piazza sul palco di Letta". 23 September 2022.
|
||||
"Letta pensa alle elezioni, ma il Pd pensa al congresso".
|
||||
"Pd: Letta, giovedì 6 ottobre direzione sul congresso". 28 September 2022.
|
||||
Nova, Redazione Agenzia (26 February 2023). "Elly Schlein è la nuova segretaria del Partito democratico". Agenzia Nova (in Italian). Retrieved 26 February 2023.
|
||||
"Enrico Letta Profile: Mild-Mannered AC Milan Fan who is Italy's Next PM". International Business Times. 24 April 2013. Retrieved 30 April 2013.
|
||||
Kington, Tom (24 April 2013). "Enrico Letta to become youngest Italian prime minister in 25 years". The Daily Telegraph. Retrieved 4 May 2013.
|
||||
Tra la passione per la politica, l'Ue e il Milan, chi è Enrico Letta, AGI – Agenzia Italiana
|
||||
Notes
|
||||
|
||||
It is not altogether clear whether the Doctorate degree was obtained in international law in 1997 as reported in his curriculum vitae,[22] or in political science in 1999 as reported by ANSA.[24]
|
||||
External links
|
||||
|
||||
Wikimedia Commons has media related to Enrico Letta.
|
||||
Personal profile of Enrico Letta in the European Parliament's database of members
|
||||
Declaration (PDF) of financial interests (in Italian)
|
||||
Political offices
|
||||
Preceded by
|
||||
Lamberto Dini
|
||||
Minister for the Community Policies
|
||||
1998–1999 Succeeded by
|
||||
Patrizia Toia
|
||||
Preceded by
|
||||
Pier Luigi Bersani
|
||||
Minister of Industry, Commerce and Crafts
|
||||
1999–2001 Succeeded by
|
||||
Antonio Marzano
|
||||
as Minister of Productive Activities
|
||||
Preceded by
|
||||
Gianni Letta
|
||||
Secretary of the Council of Ministers
|
||||
2006–2008 Succeeded by
|
||||
Gianni Letta
|
||||
Preceded by
|
||||
Mario Monti
|
||||
Prime Minister of Italy
|
||||
2013–2014 Succeeded by
|
||||
Matteo Renzi
|
||||
Party political offices
|
||||
Preceded by
|
||||
Dario Franceschini
|
||||
Deputy Secretary of the Democratic Party
|
||||
2009–2013 Succeeded by
|
||||
Debora Serracchiani
|
||||
Succeeded by
|
||||
Lorenzo Guerini
|
||||
Preceded by
|
||||
Nicola Zingaretti
|
||||
Secretary of the Democratic Party
|
||||
2021–2023 Succeeded by
|
||||
Elly Schlein
|
||||
Enrico Letta
|
||||
Authority control databases Edit this at Wikidata
|
||||
Categories: 1966 birthsLiving peoplePeople from PisaItalian Roman CatholicsChristian Democracy (Italy) politiciansItalian People's Party (1994) politiciansDemocracy is Freedom – The Daisy politiciansPrime ministers of ItalyGovernment ministers of ItalyMinisters of agriculture of ItalyDeputies of Legislature XIV of ItalyDeputies of Legislature XV of ItalyDeputies of Legislature XVI of ItalyDeputies of Legislature XVII of ItalyLetta CabinetDemocratic Party (Italy) MEPsMEPs for Italy 2004–2009University of Pisa alumniSant'Anna School of Advanced Studies alumniLeaders of political parties in Italy
|
||||
22
tests/data/test.json
Normal file
22
tests/data/test.json
Normal 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
Reference in New Issue
Block a user