letta-nightly 0.11.7.dev20251006104136__py3-none-any.whl → 0.11.7.dev20251008104128__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/adapters/letta_llm_adapter.py +1 -0
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +7 -2
- letta/adapters/simple_llm_request_adapter.py +88 -0
- letta/adapters/simple_llm_stream_adapter.py +192 -0
- letta/agents/agent_loop.py +6 -0
- letta/agents/ephemeral_summary_agent.py +2 -1
- letta/agents/helpers.py +142 -6
- letta/agents/letta_agent.py +13 -33
- letta/agents/letta_agent_batch.py +2 -4
- letta/agents/letta_agent_v2.py +87 -77
- letta/agents/letta_agent_v3.py +899 -0
- letta/agents/voice_agent.py +2 -6
- letta/constants.py +8 -4
- letta/errors.py +40 -0
- letta/functions/function_sets/base.py +84 -4
- letta/functions/function_sets/multi_agent.py +0 -3
- letta/functions/schema_generator.py +113 -71
- letta/groups/dynamic_multi_agent.py +3 -2
- letta/groups/helpers.py +1 -2
- letta/groups/round_robin_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent_v2.py +1 -1
- letta/groups/sleeptime_multi_agent_v3.py +17 -17
- letta/groups/supervisor_multi_agent.py +84 -80
- letta/helpers/converters.py +3 -0
- letta/helpers/message_helper.py +4 -0
- letta/helpers/tool_rule_solver.py +92 -5
- letta/interfaces/anthropic_streaming_interface.py +409 -0
- letta/interfaces/gemini_streaming_interface.py +296 -0
- letta/interfaces/openai_streaming_interface.py +752 -1
- letta/llm_api/anthropic_client.py +126 -16
- letta/llm_api/bedrock_client.py +4 -2
- letta/llm_api/deepseek_client.py +4 -1
- letta/llm_api/google_vertex_client.py +123 -42
- letta/llm_api/groq_client.py +4 -1
- letta/llm_api/llm_api_tools.py +11 -4
- letta/llm_api/llm_client_base.py +6 -2
- letta/llm_api/openai.py +32 -2
- letta/llm_api/openai_client.py +423 -18
- letta/llm_api/xai_client.py +4 -1
- letta/main.py +9 -5
- letta/memory.py +1 -0
- letta/orm/__init__.py +1 -1
- letta/orm/agent.py +10 -0
- letta/orm/block.py +7 -16
- letta/orm/blocks_agents.py +8 -2
- letta/orm/files_agents.py +2 -0
- letta/orm/job.py +7 -5
- letta/orm/mcp_oauth.py +1 -0
- letta/orm/message.py +21 -6
- letta/orm/organization.py +2 -0
- letta/orm/provider.py +6 -2
- letta/orm/run.py +71 -0
- letta/orm/sandbox_config.py +7 -1
- letta/orm/sqlalchemy_base.py +0 -306
- letta/orm/step.py +6 -5
- letta/orm/step_metrics.py +5 -5
- letta/otel/tracing.py +28 -3
- letta/plugins/defaults.py +4 -4
- letta/prompts/system_prompts/__init__.py +2 -0
- letta/prompts/system_prompts/letta_v1.py +25 -0
- letta/schemas/agent.py +3 -2
- letta/schemas/agent_file.py +9 -3
- letta/schemas/block.py +23 -10
- letta/schemas/enums.py +21 -2
- letta/schemas/job.py +17 -4
- letta/schemas/letta_message_content.py +71 -2
- letta/schemas/letta_stop_reason.py +5 -5
- letta/schemas/llm_config.py +53 -3
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +504 -117
- letta/schemas/openai/responses_request.py +64 -0
- letta/schemas/providers/__init__.py +2 -0
- letta/schemas/providers/anthropic.py +16 -0
- letta/schemas/providers/ollama.py +115 -33
- letta/schemas/providers/openrouter.py +52 -0
- letta/schemas/providers/vllm.py +2 -1
- letta/schemas/run.py +48 -42
- letta/schemas/step.py +2 -2
- letta/schemas/step_metrics.py +1 -1
- letta/schemas/tool.py +15 -107
- letta/schemas/tool_rule.py +88 -5
- letta/serialize_schemas/marshmallow_agent.py +1 -0
- letta/server/db.py +86 -408
- letta/server/rest_api/app.py +61 -10
- letta/server/rest_api/dependencies.py +14 -0
- letta/server/rest_api/redis_stream_manager.py +19 -8
- letta/server/rest_api/routers/v1/agents.py +364 -292
- letta/server/rest_api/routers/v1/blocks.py +14 -20
- letta/server/rest_api/routers/v1/identities.py +45 -110
- letta/server/rest_api/routers/v1/internal_templates.py +21 -0
- letta/server/rest_api/routers/v1/jobs.py +23 -6
- letta/server/rest_api/routers/v1/messages.py +1 -1
- letta/server/rest_api/routers/v1/runs.py +126 -85
- letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
- letta/server/rest_api/routers/v1/tools.py +281 -594
- letta/server/rest_api/routers/v1/voice.py +1 -1
- letta/server/rest_api/streaming_response.py +29 -29
- letta/server/rest_api/utils.py +122 -64
- letta/server/server.py +160 -887
- letta/services/agent_manager.py +236 -919
- letta/services/agent_serialization_manager.py +16 -0
- letta/services/archive_manager.py +0 -100
- letta/services/block_manager.py +211 -168
- letta/services/file_manager.py +1 -1
- letta/services/files_agents_manager.py +24 -33
- letta/services/group_manager.py +0 -142
- letta/services/helpers/agent_manager_helper.py +7 -2
- letta/services/helpers/run_manager_helper.py +85 -0
- letta/services/job_manager.py +96 -411
- letta/services/lettuce/__init__.py +6 -0
- letta/services/lettuce/lettuce_client_base.py +86 -0
- letta/services/mcp_manager.py +38 -6
- letta/services/message_manager.py +165 -362
- letta/services/organization_manager.py +0 -36
- letta/services/passage_manager.py +0 -345
- letta/services/provider_manager.py +0 -80
- letta/services/run_manager.py +301 -0
- letta/services/sandbox_config_manager.py +0 -234
- letta/services/step_manager.py +62 -39
- letta/services/summarizer/summarizer.py +9 -7
- letta/services/telemetry_manager.py +0 -16
- letta/services/tool_executor/builtin_tool_executor.py +35 -0
- letta/services/tool_executor/core_tool_executor.py +397 -2
- letta/services/tool_executor/files_tool_executor.py +3 -3
- letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
- letta/services/tool_executor/tool_execution_manager.py +6 -8
- letta/services/tool_executor/tool_executor_base.py +3 -3
- letta/services/tool_manager.py +85 -339
- letta/services/tool_sandbox/base.py +24 -13
- letta/services/tool_sandbox/e2b_sandbox.py +16 -1
- letta/services/tool_schema_generator.py +123 -0
- letta/services/user_manager.py +0 -99
- letta/settings.py +20 -4
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
- letta/agents/temporal/activities/__init__.py +0 -4
- letta/agents/temporal/activities/example_activity.py +0 -7
- letta/agents/temporal/activities/prepare_messages.py +0 -10
- letta/agents/temporal/temporal_agent_workflow.py +0 -56
- letta/agents/temporal/types.py +0 -25
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/licenses/LICENSE +0 -0
letta/server/db.py
CHANGED
@@ -1,433 +1,111 @@
|
|
1
|
-
import asyncio
|
2
|
-
import os
|
3
|
-
import threading
|
4
|
-
import time
|
5
1
|
import uuid
|
6
|
-
from contextlib import asynccontextmanager
|
7
|
-
from typing import
|
8
|
-
|
9
|
-
from
|
10
|
-
from
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
from letta.
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
cursor.execute("PRAGMA foreign_keys=ON")
|
48
|
-
cursor.close()
|
49
|
-
|
50
|
-
|
51
|
-
def on_connect(dbapi_connection, connection_record):
|
52
|
-
cursor = dbapi_connection.cursor()
|
53
|
-
cursor.execute("SELECT pg_backend_pid()")
|
54
|
-
pid = cursor.fetchone()[0]
|
55
|
-
connection_record.info["pid"] = pid
|
56
|
-
connection_record.info["connect_spawn_time_ms"] = time.perf_counter() * 1000
|
57
|
-
cursor.close()
|
58
|
-
|
59
|
-
|
60
|
-
def on_close(dbapi_connection, connection_record):
|
61
|
-
connection_record.info.get("pid")
|
62
|
-
(time.perf_counter() * 1000) - connection_record.info.get("connect_spawn_time_ms")
|
63
|
-
# print(f"Connection closed: {pid}, duration: {duration:.6f}s")
|
64
|
-
|
65
|
-
|
66
|
-
def on_checkout(dbapi_connection, connection_record, connection_proxy):
|
67
|
-
connection_record.info.get("pid")
|
68
|
-
connection_record.info["connect_checkout_time_ms"] = time.perf_counter() * 1000
|
69
|
-
|
70
|
-
|
71
|
-
def on_checkin(dbapi_connection, connection_record):
|
72
|
-
pid = connection_record.info.get("pid")
|
73
|
-
duration = (time.perf_counter() * 1000) - connection_record.info.get("connect_checkout_time_ms")
|
74
|
-
|
75
|
-
tracer = trace.get_tracer("letta.db.connection")
|
76
|
-
with tracer.start_as_current_span("connect_release") as span:
|
77
|
-
span.set_attribute("db.connection.pid", pid)
|
78
|
-
span.set_attribute("db.connection.duration_ms", duration)
|
79
|
-
span.set_attribute("db.connection.operation", "checkin")
|
80
|
-
|
81
|
-
|
82
|
-
@contextmanager
|
83
|
-
def db_error_handler():
|
84
|
-
"""Context manager for handling database errors"""
|
85
|
-
try:
|
86
|
-
yield
|
87
|
-
except Exception as e:
|
88
|
-
# Handle other SQLAlchemy errors
|
89
|
-
error_str = str(e)
|
90
|
-
|
91
|
-
# Don't exit for expected constraint violations that should be handled by the application
|
92
|
-
if "UNIQUE constraint failed" in error_str or "FOREIGN KEY constraint failed" in error_str:
|
93
|
-
# These are application-level errors that should be handled by the ORM
|
94
|
-
raise
|
95
|
-
|
96
|
-
# For other database errors, print error and exit
|
97
|
-
print(e)
|
98
|
-
print_sqlite_schema_error()
|
99
|
-
# raise ValueError(f"SQLite DB error: {str(e)}")
|
100
|
-
exit(1)
|
101
|
-
|
102
|
-
|
103
|
-
class DatabaseRegistry:
|
104
|
-
"""Registry for database connections and sessions.
|
105
|
-
|
106
|
-
This class manages both synchronous and asynchronous database connections
|
107
|
-
and provides context managers for session handling.
|
108
|
-
"""
|
109
|
-
|
110
|
-
def __init__(self):
|
111
|
-
self._engines: dict[str, Engine] = {}
|
112
|
-
self._async_engines: dict[str, AsyncEngine] = {}
|
113
|
-
self._session_factories: dict[str, sessionmaker] = {}
|
114
|
-
self._async_session_factories: dict[str, async_sessionmaker] = {}
|
115
|
-
self._initialized: dict[str, bool] = {"sync": False, "async": False}
|
116
|
-
self._lock = threading.Lock()
|
117
|
-
self.config = LettaConfig.load()
|
118
|
-
self.logger = get_logger(__name__)
|
119
|
-
|
120
|
-
if settings.db_max_concurrent_sessions:
|
121
|
-
self._db_semaphore = asyncio.Semaphore(settings.db_max_concurrent_sessions)
|
122
|
-
self.logger.info(f"Initialized database throttling with max {settings.db_max_concurrent_sessions} concurrent sessions")
|
123
|
-
else:
|
124
|
-
self.logger.info("Database throttling is disabled")
|
125
|
-
self._db_semaphore = None
|
126
|
-
|
127
|
-
def initialize_sync(self, force: bool = False) -> None:
|
128
|
-
"""Initialize the synchronous database engine if not already initialized."""
|
129
|
-
with self._lock:
|
130
|
-
if self._initialized.get("sync") and not force:
|
131
|
-
return
|
132
|
-
|
133
|
-
# Postgres engine
|
134
|
-
if settings.database_engine is DatabaseChoice.POSTGRES:
|
135
|
-
self.logger.info("Creating postgres engine")
|
136
|
-
self.config.recall_storage_type = "postgres"
|
137
|
-
self.config.recall_storage_uri = settings.letta_pg_uri_no_default
|
138
|
-
self.config.archival_storage_type = "postgres"
|
139
|
-
self.config.archival_storage_uri = settings.letta_pg_uri_no_default
|
140
|
-
|
141
|
-
engine = create_engine(settings.letta_pg_uri, **self._build_sqlalchemy_engine_args(is_async=False))
|
142
|
-
|
143
|
-
self._engines["default"] = engine
|
144
|
-
# SQLite engine
|
145
|
-
else:
|
146
|
-
from letta.orm import Base
|
147
|
-
|
148
|
-
# TODO: don't rely on config storage
|
149
|
-
engine_path = "sqlite:///" + os.path.join(self.config.recall_storage_path, "sqlite.db")
|
150
|
-
self.logger.info("Creating sqlite engine " + engine_path)
|
151
|
-
|
152
|
-
engine = create_engine(engine_path)
|
153
|
-
|
154
|
-
# Wrap the engine with error handling
|
155
|
-
self._wrap_sqlite_engine(engine)
|
156
|
-
|
157
|
-
Base.metadata.create_all(bind=engine)
|
158
|
-
self._engines["default"] = engine
|
159
|
-
|
160
|
-
# Set up connection monitoring
|
161
|
-
if settings.sqlalchemy_tracing and settings.database_engine is DatabaseChoice.POSTGRES:
|
162
|
-
event.listen(engine, "connect", on_connect)
|
163
|
-
event.listen(engine, "close", on_close)
|
164
|
-
event.listen(engine, "checkout", on_checkout)
|
165
|
-
event.listen(engine, "checkin", on_checkin)
|
166
|
-
|
167
|
-
self._setup_pool_monitoring(engine, "default")
|
168
|
-
|
169
|
-
# Create session factory
|
170
|
-
self._session_factories["default"] = sessionmaker(autocommit=False, autoflush=False, bind=self._engines["default"])
|
171
|
-
self._initialized["sync"] = True
|
172
|
-
|
173
|
-
def initialize_async(self, force: bool = False) -> None:
|
174
|
-
"""Initialize the asynchronous database engine if not already initialized."""
|
175
|
-
with self._lock:
|
176
|
-
if self._initialized.get("async") and not force:
|
177
|
-
return
|
178
|
-
|
179
|
-
if settings.database_engine is DatabaseChoice.POSTGRES:
|
180
|
-
self.logger.info("Creating async postgres engine")
|
181
|
-
|
182
|
-
# Create async engine - convert URI to async format
|
183
|
-
pg_uri = settings.letta_pg_uri
|
184
|
-
if pg_uri.startswith("postgresql://"):
|
185
|
-
async_pg_uri = pg_uri.replace("postgresql://", "postgresql+asyncpg://")
|
186
|
-
else:
|
187
|
-
async_pg_uri = f"postgresql+asyncpg://{pg_uri.split('://', 1)[1]}" if "://" in pg_uri else pg_uri
|
188
|
-
async_pg_uri = async_pg_uri.replace("sslmode=", "ssl=")
|
189
|
-
async_engine = create_async_engine(async_pg_uri, **self._build_sqlalchemy_engine_args(is_async=True))
|
190
|
-
else:
|
191
|
-
# create sqlite async engine
|
192
|
-
self._initialized["async"] = False
|
193
|
-
# TODO: remove self.config
|
194
|
-
engine_path = "sqlite+aiosqlite:///" + os.path.join(self.config.recall_storage_path, "sqlite.db")
|
195
|
-
self.logger.info("Creating sqlite engine " + engine_path)
|
196
|
-
async_engine = create_async_engine(engine_path, **self._build_sqlalchemy_engine_args(is_async=True))
|
197
|
-
|
198
|
-
# Enable foreign keys for SQLite async connections
|
199
|
-
@event.listens_for(async_engine.sync_engine, "connect")
|
200
|
-
def enable_sqlite_foreign_keys_async(dbapi_connection, connection_record):
|
201
|
-
cursor = dbapi_connection.cursor()
|
202
|
-
cursor.execute("PRAGMA foreign_keys=ON")
|
203
|
-
cursor.close()
|
204
|
-
|
205
|
-
# Create async session factory
|
206
|
-
self._async_engines["default"] = async_engine
|
207
|
-
|
208
|
-
# Set up connection monitoring for async engine
|
209
|
-
if settings.sqlalchemy_tracing and settings.database_engine is DatabaseChoice.POSTGRES:
|
210
|
-
event.listen(async_engine.sync_engine, "connect", on_connect)
|
211
|
-
event.listen(async_engine.sync_engine, "close", on_close)
|
212
|
-
event.listen(async_engine.sync_engine, "checkout", on_checkout)
|
213
|
-
event.listen(async_engine.sync_engine, "checkin", on_checkin)
|
214
|
-
|
215
|
-
self._setup_pool_monitoring(async_engine, "default_async")
|
216
|
-
|
217
|
-
self._async_session_factories["default"] = async_sessionmaker(
|
218
|
-
expire_on_commit=False,
|
219
|
-
close_resets_only=False,
|
220
|
-
autocommit=False,
|
221
|
-
autoflush=False,
|
222
|
-
bind=self._async_engines["default"],
|
223
|
-
class_=AsyncSession,
|
224
|
-
)
|
225
|
-
self._initialized["async"] = True
|
226
|
-
|
227
|
-
def _build_sqlalchemy_engine_args(self, *, is_async: bool) -> dict:
|
228
|
-
"""Prepare keyword arguments for create_engine / create_async_engine."""
|
229
|
-
# For async SQLite, always use NullPool to avoid cleanup issues during cancellation
|
230
|
-
if is_async and settings.database_engine is DatabaseChoice.SQLITE:
|
231
|
-
use_null_pool = True
|
232
|
-
logger.info("Forcing NullPool for async SQLite to avoid cancellation cleanup issues")
|
233
|
-
else:
|
234
|
-
use_null_pool = settings.disable_sqlalchemy_pooling
|
235
|
-
|
236
|
-
if use_null_pool:
|
237
|
-
logger.info("Disabling pooling on SqlAlchemy")
|
238
|
-
pool_cls = NullPool
|
239
|
-
else:
|
240
|
-
logger.info("Enabling pooling on SqlAlchemy")
|
241
|
-
# AsyncAdaptedQueuePool will be the default if none is provided for async but setting this explicitly.
|
242
|
-
from sqlalchemy import AsyncAdaptedQueuePool
|
243
|
-
|
244
|
-
pool_cls = QueuePool if not is_async else AsyncAdaptedQueuePool
|
245
|
-
|
246
|
-
base_args = {
|
247
|
-
"echo": settings.pg_echo,
|
248
|
-
"pool_pre_ping": settings.pool_pre_ping,
|
2
|
+
from contextlib import asynccontextmanager
|
3
|
+
from typing import AsyncGenerator
|
4
|
+
|
5
|
+
from sqlalchemy import NullPool
|
6
|
+
from sqlalchemy.ext.asyncio import (
|
7
|
+
AsyncEngine,
|
8
|
+
AsyncSession,
|
9
|
+
async_sessionmaker,
|
10
|
+
create_async_engine,
|
11
|
+
)
|
12
|
+
|
13
|
+
from letta.settings import settings
|
14
|
+
|
15
|
+
# Convert PostgreSQL URI to async format
|
16
|
+
pg_uri = settings.letta_pg_uri
|
17
|
+
if pg_uri.startswith("postgresql://"):
|
18
|
+
async_pg_uri = pg_uri.replace("postgresql://", "postgresql+asyncpg://")
|
19
|
+
else:
|
20
|
+
# Handle other URI formats (e.g., postgresql+pg8000://)
|
21
|
+
async_pg_uri = f"postgresql+asyncpg://{pg_uri.split('://', 1)[1]}" if "://" in pg_uri else pg_uri
|
22
|
+
|
23
|
+
# Replace sslmode with ssl for asyncpg
|
24
|
+
async_pg_uri = async_pg_uri.replace("sslmode=", "ssl=")
|
25
|
+
|
26
|
+
# Build engine configuration based on settings
|
27
|
+
engine_args = {
|
28
|
+
"echo": settings.pg_echo,
|
29
|
+
"pool_pre_ping": settings.pool_pre_ping,
|
30
|
+
}
|
31
|
+
|
32
|
+
# Configure pooling
|
33
|
+
if settings.disable_sqlalchemy_pooling:
|
34
|
+
engine_args["poolclass"] = NullPool
|
35
|
+
else:
|
36
|
+
# Use default AsyncAdaptedQueuePool with configured settings
|
37
|
+
engine_args.update(
|
38
|
+
{
|
39
|
+
"pool_size": settings.pg_pool_size,
|
40
|
+
"max_overflow": settings.pg_max_overflow,
|
41
|
+
"pool_timeout": settings.pg_pool_timeout,
|
42
|
+
"pool_recycle": settings.pg_pool_recycle,
|
249
43
|
}
|
44
|
+
)
|
45
|
+
|
46
|
+
# Add asyncpg-specific settings for connection
|
47
|
+
if not settings.disable_sqlalchemy_pooling:
|
48
|
+
engine_args["connect_args"] = {
|
49
|
+
"timeout": settings.pg_pool_timeout,
|
50
|
+
"prepared_statement_name_func": lambda: f"__asyncpg_{uuid.uuid4()}__",
|
51
|
+
"statement_cache_size": 0,
|
52
|
+
"prepared_statement_cache_size": 0,
|
53
|
+
}
|
54
|
+
|
55
|
+
# Create the engine once at module level
|
56
|
+
engine: AsyncEngine = create_async_engine(async_pg_uri, **engine_args)
|
57
|
+
|
58
|
+
# Create session factory once at module level
|
59
|
+
async_session_factory = async_sessionmaker(
|
60
|
+
engine,
|
61
|
+
class_=AsyncSession,
|
62
|
+
expire_on_commit=False,
|
63
|
+
autocommit=False,
|
64
|
+
autoflush=False,
|
65
|
+
)
|
250
66
|
|
251
|
-
if pool_cls:
|
252
|
-
base_args["poolclass"] = pool_cls
|
253
|
-
|
254
|
-
if not use_null_pool:
|
255
|
-
base_args.update(
|
256
|
-
{
|
257
|
-
"pool_size": settings.pg_pool_size,
|
258
|
-
"max_overflow": settings.pg_max_overflow,
|
259
|
-
"pool_timeout": settings.pg_pool_timeout,
|
260
|
-
"pool_recycle": settings.pg_pool_recycle,
|
261
|
-
}
|
262
|
-
)
|
263
|
-
if not is_async:
|
264
|
-
base_args.update(
|
265
|
-
{
|
266
|
-
"pool_use_lifo": settings.pool_use_lifo,
|
267
|
-
}
|
268
|
-
)
|
269
|
-
|
270
|
-
elif is_async and settings.database_engine is DatabaseChoice.POSTGRES:
|
271
|
-
# Invalid for SQLite, results in [0] TypeError: 'prepared_statement_name_func' is an invalid keyword argument for Connection()
|
272
|
-
# For asyncpg, statement_cache_size should be in connect_args
|
273
|
-
base_args.update(
|
274
|
-
{
|
275
|
-
"connect_args": {
|
276
|
-
"timeout": settings.pg_pool_timeout,
|
277
|
-
"prepared_statement_name_func": lambda: f"__asyncpg_{uuid.uuid4()}__",
|
278
|
-
"statement_cache_size": 0,
|
279
|
-
"prepared_statement_cache_size": 0,
|
280
|
-
},
|
281
|
-
}
|
282
|
-
)
|
283
|
-
return base_args
|
284
|
-
|
285
|
-
def _wrap_sqlite_engine(self, engine: Engine) -> None:
|
286
|
-
"""Wrap SQLite engine with error handling."""
|
287
|
-
original_connect = engine.connect
|
288
|
-
|
289
|
-
def wrapped_connect(*args, **kwargs):
|
290
|
-
with db_error_handler():
|
291
|
-
connection = original_connect(*args, **kwargs)
|
292
|
-
original_execute = connection.execute
|
293
|
-
|
294
|
-
def wrapped_execute(*args, **kwargs):
|
295
|
-
with db_error_handler():
|
296
|
-
return original_execute(*args, **kwargs)
|
297
|
-
|
298
|
-
connection.execute = wrapped_execute
|
299
|
-
return connection
|
300
|
-
|
301
|
-
engine.connect = wrapped_connect
|
302
|
-
|
303
|
-
def _setup_pool_monitoring(self, engine: Engine | AsyncEngine, engine_name: str) -> None:
|
304
|
-
"""Set up database pool monitoring for the given engine."""
|
305
|
-
if not settings.enable_db_pool_monitoring:
|
306
|
-
return
|
307
|
-
|
308
|
-
try:
|
309
|
-
from letta.otel.db_pool_monitoring import setup_pool_monitoring
|
310
|
-
|
311
|
-
setup_pool_monitoring(engine, engine_name)
|
312
|
-
self.logger.info(f"Database pool monitoring enabled for {engine_name}")
|
313
|
-
except ImportError:
|
314
|
-
self.logger.warning("Database pool monitoring not available - missing dependencies")
|
315
|
-
except Exception as e:
|
316
|
-
self.logger.warning(f"Failed to setup pool monitoring for {engine_name}: {e}")
|
317
67
|
|
318
|
-
|
319
|
-
|
320
|
-
self.initialize_sync()
|
321
|
-
return self._engines.get(name)
|
322
|
-
|
323
|
-
def get_async_engine(self, name: str = "default") -> Engine:
|
324
|
-
"""Get a database engine by name."""
|
325
|
-
self.initialize_async()
|
326
|
-
return self._async_engines.get(name)
|
327
|
-
|
328
|
-
def get_session_factory(self, name: str = "default") -> sessionmaker:
|
329
|
-
"""Get a session factory by name."""
|
330
|
-
self.initialize_sync()
|
331
|
-
return self._session_factories.get(name)
|
332
|
-
|
333
|
-
def get_async_session_factory(self, name: str = "default") -> async_sessionmaker:
|
334
|
-
"""Get an async session factory by name."""
|
335
|
-
self.initialize_async()
|
336
|
-
return self._async_session_factories.get(name)
|
337
|
-
|
338
|
-
@trace_method
|
339
|
-
@contextmanager
|
340
|
-
def session(self, name: str = "default") -> Generator[Any, None, None]:
|
341
|
-
"""Context manager for database sessions."""
|
342
|
-
caller_info = "unknown caller"
|
343
|
-
try:
|
344
|
-
import inspect
|
345
|
-
|
346
|
-
frame = inspect.currentframe()
|
347
|
-
stack = inspect.getouterframes(frame)
|
348
|
-
|
349
|
-
for i, frame_info in enumerate(stack):
|
350
|
-
module = inspect.getmodule(frame_info.frame)
|
351
|
-
module_name = module.__name__ if module else "unknown"
|
352
|
-
|
353
|
-
if module_name != "contextlib" and "db.py" not in frame_info.filename:
|
354
|
-
caller_module = module_name
|
355
|
-
caller_function = frame_info.function
|
356
|
-
caller_lineno = frame_info.lineno
|
357
|
-
caller_file = frame_info.filename.split("/")[-1]
|
358
|
-
|
359
|
-
caller_info = f"{caller_module}.{caller_function}:{caller_lineno} ({caller_file})"
|
360
|
-
break
|
361
|
-
except:
|
362
|
-
pass
|
363
|
-
finally:
|
364
|
-
del frame
|
365
|
-
|
366
|
-
self.session_caller_trace(caller_info)
|
367
|
-
|
368
|
-
session_factory = self.get_session_factory(name)
|
369
|
-
if not session_factory:
|
370
|
-
raise ValueError(f"No session factory found for '{name}'")
|
371
|
-
|
372
|
-
session = session_factory()
|
373
|
-
try:
|
374
|
-
yield session
|
375
|
-
finally:
|
376
|
-
session.close()
|
68
|
+
class DatabaseRegistry:
|
69
|
+
"""Dummy registry to maintain the existing interface."""
|
377
70
|
|
378
|
-
@trace_method
|
379
71
|
@asynccontextmanager
|
380
|
-
async def async_session(self
|
381
|
-
"""
|
382
|
-
|
383
|
-
async with self._db_semaphore:
|
384
|
-
session_factory = self.get_async_session_factory(name)
|
385
|
-
if not session_factory:
|
386
|
-
raise ValueError(f"No async session factory found for '{name}' or async database is not configured")
|
387
|
-
|
388
|
-
session = session_factory()
|
389
|
-
try:
|
390
|
-
yield session
|
391
|
-
finally:
|
392
|
-
await session.close()
|
393
|
-
else:
|
394
|
-
session_factory = self.get_async_session_factory(name)
|
395
|
-
if not session_factory:
|
396
|
-
raise ValueError(f"No async session factory found for '{name}' or async database is not configured")
|
397
|
-
|
398
|
-
session = session_factory()
|
72
|
+
async def async_session(self) -> AsyncGenerator[AsyncSession, None]:
|
73
|
+
"""Get an async database session."""
|
74
|
+
async with async_session_factory() as session:
|
399
75
|
try:
|
400
76
|
yield session
|
77
|
+
await session.commit()
|
78
|
+
except Exception:
|
79
|
+
await session.rollback()
|
80
|
+
raise
|
401
81
|
finally:
|
402
82
|
await session.close()
|
403
83
|
|
404
|
-
@trace_method
|
405
|
-
def session_caller_trace(self, caller_info: str):
|
406
|
-
"""Trace sync db caller information for debugging purposes."""
|
407
|
-
pass # wrapper used for otel tracing only
|
408
|
-
|
409
84
|
|
410
|
-
# Create
|
85
|
+
# Create singleton instance to match existing interface
|
411
86
|
db_registry = DatabaseRegistry()
|
412
87
|
|
413
88
|
|
89
|
+
# Backwards compatibility function
|
414
90
|
def get_db_registry() -> DatabaseRegistry:
|
415
91
|
"""Get the global database registry instance."""
|
416
92
|
return db_registry
|
417
93
|
|
418
94
|
|
419
|
-
|
420
|
-
|
421
|
-
with db_registry.session() as session:
|
422
|
-
yield session
|
423
|
-
|
424
|
-
|
425
|
-
async def get_db_async():
|
95
|
+
# FastAPI dependency helper
|
96
|
+
async def get_db_async() -> AsyncGenerator[AsyncSession, None]:
|
426
97
|
"""Get an async database session."""
|
427
98
|
async with db_registry.async_session() as session:
|
428
99
|
yield session
|
429
100
|
|
430
101
|
|
431
|
-
#
|
432
|
-
|
433
|
-
|
102
|
+
# Optional: cleanup function for graceful shutdown
|
103
|
+
async def close_db() -> None:
|
104
|
+
"""Close the database engine."""
|
105
|
+
await engine.dispose()
|
106
|
+
|
107
|
+
|
108
|
+
# Usage remains the same:
|
109
|
+
# async with db_registry.async_session() as session:
|
110
|
+
# result = await session.execute(select(User))
|
111
|
+
# users = result.scalars().all()
|
letta/server/rest_api/app.py
CHANGED
@@ -12,14 +12,26 @@ from typing import Optional
|
|
12
12
|
import uvicorn
|
13
13
|
from fastapi import FastAPI, Request
|
14
14
|
from fastapi.responses import JSONResponse
|
15
|
+
from marshmallow import ValidationError
|
16
|
+
from sqlalchemy.exc import IntegrityError, OperationalError
|
15
17
|
from starlette.middleware.cors import CORSMiddleware
|
16
18
|
|
17
19
|
from letta.__init__ import __version__ as letta_version
|
18
20
|
from letta.agents.exceptions import IncompatibleAgentType
|
19
21
|
from letta.constants import ADMIN_PREFIX, API_PREFIX, OPENAI_API_PREFIX
|
20
22
|
from letta.errors import (
|
23
|
+
AgentExportIdMappingError,
|
24
|
+
AgentExportProcessingError,
|
25
|
+
AgentFileImportError,
|
26
|
+
AgentNotFoundForExportError,
|
21
27
|
BedrockPermissionError,
|
22
28
|
LettaAgentNotFoundError,
|
29
|
+
LettaInvalidArgumentError,
|
30
|
+
LettaInvalidMCPSchemaError,
|
31
|
+
LettaMCPConnectionError,
|
32
|
+
LettaMCPTimeoutError,
|
33
|
+
LettaToolCreateError,
|
34
|
+
LettaToolNameConflictError,
|
23
35
|
LettaUserNotFoundError,
|
24
36
|
LLMAuthenticationError,
|
25
37
|
LLMError,
|
@@ -44,7 +56,6 @@ from letta.server.db import db_registry
|
|
44
56
|
from letta.server.rest_api.auth.index import setup_auth_router # TODO: probably remove right?
|
45
57
|
from letta.server.rest_api.interface import StreamingServerInterface
|
46
58
|
from letta.server.rest_api.middleware import CheckPasswordMiddleware, ProfilerContextMiddleware
|
47
|
-
from letta.server.rest_api.routers.openai.chat_completions.chat_completions import router as openai_chat_completions_router
|
48
59
|
from letta.server.rest_api.routers.v1 import ROUTERS as v1_routes
|
49
60
|
from letta.server.rest_api.routers.v1.organizations import router as organizations_router
|
50
61
|
from letta.server.rest_api.routers.v1.users import router as users_router # TODO: decide on admin
|
@@ -122,11 +133,10 @@ async def lifespan(app_: FastAPI):
|
|
122
133
|
except Exception as exc:
|
123
134
|
logger.info("Profiler not enabled: %", exc)
|
124
135
|
|
125
|
-
logger.info(f"[Worker {worker_id}] Starting lifespan initialization")
|
126
|
-
logger.info(f"[Worker {worker_id}] Initializing database connections")
|
127
|
-
db_registry.
|
128
|
-
|
129
|
-
logger.info(f"[Worker {worker_id}] Database connections initialized")
|
136
|
+
# logger.info(f"[Worker {worker_id}] Starting lifespan initialization")
|
137
|
+
# logger.info(f"[Worker {worker_id}] Initializing database connections")
|
138
|
+
# db_registry.initialize_async()
|
139
|
+
# logger.info(f"[Worker {worker_id}] Database connections initialized")
|
130
140
|
|
131
141
|
if should_use_pinecone():
|
132
142
|
if settings.upsert_pinecone_indices:
|
@@ -140,6 +150,7 @@ async def lifespan(app_: FastAPI):
|
|
140
150
|
|
141
151
|
logger.info(f"[Worker {worker_id}] Starting scheduler with leader election")
|
142
152
|
global server
|
153
|
+
await server.init_async()
|
143
154
|
try:
|
144
155
|
await start_scheduler_with_leader_election(server)
|
145
156
|
logger.info(f"[Worker {worker_id}] Scheduler initialization completed")
|
@@ -180,6 +191,7 @@ def create_application() -> "FastAPI":
|
|
180
191
|
if SENTRY_ENABLED:
|
181
192
|
sentry_sdk.init(
|
182
193
|
dsn=os.getenv("SENTRY_DSN"),
|
194
|
+
environment=os.getenv("LETTA_ENVIRONMENT", "undefined"),
|
183
195
|
traces_sample_rate=1.0,
|
184
196
|
_experiments={
|
185
197
|
"continuous_profiling_auto_start": True,
|
@@ -232,14 +244,43 @@ def create_application() -> "FastAPI":
|
|
232
244
|
_error_handler_404 = partial(error_handler_with_code, code=404)
|
233
245
|
_error_handler_404_agent = partial(_error_handler_404, detail="Agent not found")
|
234
246
|
_error_handler_404_user = partial(_error_handler_404, detail="User not found")
|
247
|
+
_error_handler_408 = partial(error_handler_with_code, code=408)
|
235
248
|
_error_handler_409 = partial(error_handler_with_code, code=409)
|
236
|
-
|
249
|
+
_error_handler_422 = partial(error_handler_with_code, code=422)
|
250
|
+
_error_handler_500 = partial(error_handler_with_code, code=500)
|
251
|
+
_error_handler_503 = partial(error_handler_with_code, code=503)
|
252
|
+
|
253
|
+
# 400 Bad Request errors
|
254
|
+
app.add_exception_handler(LettaInvalidArgumentError, _error_handler_400)
|
255
|
+
app.add_exception_handler(LettaToolCreateError, _error_handler_400)
|
256
|
+
app.add_exception_handler(LettaToolNameConflictError, _error_handler_400)
|
257
|
+
app.add_exception_handler(AgentFileImportError, _error_handler_400)
|
237
258
|
app.add_exception_handler(ValueError, _error_handler_400)
|
259
|
+
|
260
|
+
# 404 Not Found errors
|
238
261
|
app.add_exception_handler(NoResultFound, _error_handler_404)
|
239
262
|
app.add_exception_handler(LettaAgentNotFoundError, _error_handler_404_agent)
|
240
263
|
app.add_exception_handler(LettaUserNotFoundError, _error_handler_404_user)
|
264
|
+
app.add_exception_handler(AgentNotFoundForExportError, _error_handler_404)
|
265
|
+
|
266
|
+
# 408 Timeout errors
|
267
|
+
app.add_exception_handler(LettaMCPTimeoutError, _error_handler_408)
|
268
|
+
app.add_exception_handler(LettaInvalidMCPSchemaError, _error_handler_400)
|
269
|
+
|
270
|
+
# 409 Conflict errors
|
241
271
|
app.add_exception_handler(ForeignKeyConstraintViolationError, _error_handler_409)
|
242
272
|
app.add_exception_handler(UniqueConstraintViolationError, _error_handler_409)
|
273
|
+
app.add_exception_handler(IntegrityError, _error_handler_409)
|
274
|
+
|
275
|
+
# 422 Validation errors
|
276
|
+
app.add_exception_handler(ValidationError, _error_handler_422)
|
277
|
+
|
278
|
+
# 500 Internal Server errors
|
279
|
+
app.add_exception_handler(AgentExportIdMappingError, _error_handler_500)
|
280
|
+
app.add_exception_handler(AgentExportProcessingError, _error_handler_500)
|
281
|
+
|
282
|
+
# 503 Service Unavailable errors
|
283
|
+
app.add_exception_handler(OperationalError, _error_handler_503)
|
243
284
|
|
244
285
|
@app.exception_handler(IncompatibleAgentType)
|
245
286
|
async def handle_incompatible_agent_type(request: Request, exc: IncompatibleAgentType):
|
@@ -323,6 +364,19 @@ def create_application() -> "FastAPI":
|
|
323
364
|
},
|
324
365
|
)
|
325
366
|
|
367
|
+
@app.exception_handler(LettaMCPConnectionError)
|
368
|
+
async def mcp_connection_error_handler(request: Request, exc: LettaMCPConnectionError):
|
369
|
+
return JSONResponse(
|
370
|
+
status_code=502,
|
371
|
+
content={
|
372
|
+
"error": {
|
373
|
+
"type": "mcp_connection_error",
|
374
|
+
"message": "Failed to connect to MCP server.",
|
375
|
+
"detail": str(exc),
|
376
|
+
}
|
377
|
+
},
|
378
|
+
)
|
379
|
+
|
326
380
|
@app.exception_handler(LLMError)
|
327
381
|
async def llm_error_handler(request: Request, exc: LLMError):
|
328
382
|
return JSONResponse(
|
@@ -400,9 +454,6 @@ def create_application() -> "FastAPI":
|
|
400
454
|
app.include_router(users_router, prefix=ADMIN_PREFIX)
|
401
455
|
app.include_router(organizations_router, prefix=ADMIN_PREFIX)
|
402
456
|
|
403
|
-
# openai
|
404
|
-
app.include_router(openai_chat_completions_router, prefix=OPENAI_API_PREFIX)
|
405
|
-
|
406
457
|
# /api/auth endpoints
|
407
458
|
app.include_router(setup_auth_router(server, interface, random_password), prefix=API_PREFIX)
|
408
459
|
|