letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721070720__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.
Files changed (99) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +27 -11
  3. letta/agents/helpers.py +1 -1
  4. letta/agents/letta_agent.py +518 -322
  5. letta/agents/letta_agent_batch.py +1 -2
  6. letta/agents/voice_agent.py +15 -17
  7. letta/client/client.py +3 -3
  8. letta/constants.py +5 -0
  9. letta/embeddings.py +0 -2
  10. letta/errors.py +8 -0
  11. letta/functions/function_sets/base.py +3 -3
  12. letta/functions/helpers.py +2 -3
  13. letta/groups/sleeptime_multi_agent.py +0 -1
  14. letta/helpers/composio_helpers.py +2 -2
  15. letta/helpers/converters.py +1 -1
  16. letta/helpers/pinecone_utils.py +8 -0
  17. letta/helpers/tool_rule_solver.py +13 -18
  18. letta/llm_api/aws_bedrock.py +16 -2
  19. letta/llm_api/cohere.py +1 -1
  20. letta/llm_api/openai_client.py +1 -1
  21. letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
  22. letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
  23. letta/local_llm/utils.py +1 -2
  24. letta/orm/agent.py +3 -3
  25. letta/orm/block.py +4 -4
  26. letta/orm/files_agents.py +0 -1
  27. letta/orm/identity.py +2 -0
  28. letta/orm/mcp_server.py +0 -2
  29. letta/orm/message.py +140 -14
  30. letta/orm/organization.py +5 -5
  31. letta/orm/passage.py +4 -4
  32. letta/orm/source.py +1 -1
  33. letta/orm/sqlalchemy_base.py +61 -39
  34. letta/orm/step.py +2 -0
  35. letta/otel/db_pool_monitoring.py +308 -0
  36. letta/otel/metric_registry.py +94 -1
  37. letta/otel/sqlalchemy_instrumentation.py +548 -0
  38. letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
  39. letta/otel/tracing.py +37 -1
  40. letta/schemas/agent.py +0 -3
  41. letta/schemas/agent_file.py +283 -0
  42. letta/schemas/block.py +0 -3
  43. letta/schemas/file.py +28 -26
  44. letta/schemas/letta_message.py +15 -4
  45. letta/schemas/memory.py +1 -1
  46. letta/schemas/message.py +31 -26
  47. letta/schemas/openai/chat_completion_response.py +0 -1
  48. letta/schemas/providers.py +20 -0
  49. letta/schemas/source.py +11 -13
  50. letta/schemas/step.py +12 -0
  51. letta/schemas/tool.py +0 -4
  52. letta/serialize_schemas/marshmallow_agent.py +14 -1
  53. letta/serialize_schemas/marshmallow_block.py +23 -1
  54. letta/serialize_schemas/marshmallow_message.py +1 -3
  55. letta/serialize_schemas/marshmallow_tool.py +23 -1
  56. letta/server/db.py +110 -6
  57. letta/server/rest_api/app.py +85 -73
  58. letta/server/rest_api/routers/v1/agents.py +68 -53
  59. letta/server/rest_api/routers/v1/blocks.py +2 -2
  60. letta/server/rest_api/routers/v1/jobs.py +3 -0
  61. letta/server/rest_api/routers/v1/organizations.py +2 -2
  62. letta/server/rest_api/routers/v1/sources.py +18 -2
  63. letta/server/rest_api/routers/v1/tools.py +11 -12
  64. letta/server/rest_api/routers/v1/users.py +1 -1
  65. letta/server/rest_api/streaming_response.py +13 -5
  66. letta/server/rest_api/utils.py +8 -25
  67. letta/server/server.py +11 -4
  68. letta/server/ws_api/server.py +2 -2
  69. letta/services/agent_file_manager.py +616 -0
  70. letta/services/agent_manager.py +133 -46
  71. letta/services/block_manager.py +38 -17
  72. letta/services/file_manager.py +106 -21
  73. letta/services/file_processor/file_processor.py +93 -0
  74. letta/services/files_agents_manager.py +28 -0
  75. letta/services/group_manager.py +4 -5
  76. letta/services/helpers/agent_manager_helper.py +57 -9
  77. letta/services/identity_manager.py +22 -0
  78. letta/services/job_manager.py +210 -91
  79. letta/services/llm_batch_manager.py +9 -6
  80. letta/services/mcp/stdio_client.py +1 -2
  81. letta/services/mcp_manager.py +0 -1
  82. letta/services/message_manager.py +49 -26
  83. letta/services/passage_manager.py +0 -1
  84. letta/services/provider_manager.py +1 -1
  85. letta/services/source_manager.py +114 -5
  86. letta/services/step_manager.py +36 -4
  87. letta/services/telemetry_manager.py +9 -2
  88. letta/services/tool_executor/builtin_tool_executor.py +5 -1
  89. letta/services/tool_executor/core_tool_executor.py +3 -3
  90. letta/services/tool_manager.py +95 -20
  91. letta/services/user_manager.py +4 -12
  92. letta/settings.py +23 -6
  93. letta/system.py +1 -1
  94. letta/utils.py +26 -2
  95. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
  96. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
  97. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
  98. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
  99. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  from typing import List, Optional
3
3
 
4
- from sqlalchemy import select
4
+ from sqlalchemy import and_, exists, select
5
5
 
6
6
  from letta.orm import Agent as AgentModel
7
7
  from letta.orm.errors import NoResultFound
@@ -19,6 +19,30 @@ from letta.utils import enforce_types, printd
19
19
  class SourceManager:
20
20
  """Manager class to handle business logic related to Sources."""
21
21
 
22
+ @trace_method
23
+ async def _validate_source_exists_async(self, session, source_id: str, actor: PydanticUser) -> None:
24
+ """
25
+ Validate that a source exists and user has access to it using raw SQL for efficiency.
26
+
27
+ Args:
28
+ session: Database session
29
+ source_id: ID of the source to validate
30
+ actor: User performing the action
31
+
32
+ Raises:
33
+ NoResultFound: If source doesn't exist or user doesn't have access
34
+ """
35
+ source_exists_query = select(
36
+ exists().where(
37
+ and_(SourceModel.id == source_id, SourceModel.organization_id == actor.organization_id, SourceModel.is_deleted == False)
38
+ )
39
+ )
40
+
41
+ result = await session.execute(source_exists_query)
42
+
43
+ if not result.scalar():
44
+ raise NoResultFound(f"Source with ID {source_id} not found")
45
+
22
46
  @enforce_types
23
47
  @trace_method
24
48
  async def create_source(self, source: PydanticSource, actor: PydanticUser) -> PydanticSource:
@@ -93,20 +117,20 @@ class SourceManager:
93
117
 
94
118
  @enforce_types
95
119
  @trace_method
96
- async def list_attached_agents(self, source_id: str, actor: Optional[PydanticUser] = None) -> List[PydanticAgentState]:
120
+ async def list_attached_agents(self, source_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
97
121
  """
98
122
  Lists all agents that have the specified source attached.
99
123
 
100
124
  Args:
101
125
  source_id: ID of the source to find attached agents for
102
- actor: User performing the action (optional for now, following existing pattern)
126
+ actor: User performing the action
103
127
 
104
128
  Returns:
105
129
  List[PydanticAgentState]: List of agents that have this source attached
106
130
  """
107
131
  async with db_registry.async_session() as session:
108
132
  # Verify source exists and user has permission to access it
109
- source = await SourceModel.read_async(db_session=session, identifier=source_id, actor=actor)
133
+ await self._validate_source_exists_async(session, source_id, actor)
110
134
 
111
135
  # Use junction table query instead of relationship to avoid performance issues
112
136
  query = (
@@ -114,7 +138,7 @@ class SourceManager:
114
138
  .join(SourcesAgents, AgentModel.id == SourcesAgents.agent_id)
115
139
  .where(
116
140
  SourcesAgents.source_id == source_id,
117
- AgentModel.organization_id == actor.organization_id if actor else True,
141
+ AgentModel.organization_id == actor.organization_id,
118
142
  AgentModel.is_deleted == False,
119
143
  )
120
144
  .order_by(AgentModel.created_at.desc(), AgentModel.id)
@@ -125,6 +149,31 @@ class SourceManager:
125
149
 
126
150
  return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents_orm])
127
151
 
152
+ @enforce_types
153
+ @trace_method
154
+ async def get_agents_for_source_id(self, source_id: str, actor: PydanticUser) -> List[str]:
155
+ """
156
+ Get all agent IDs associated with a given source ID.
157
+
158
+ Args:
159
+ source_id: ID of the source to find agents for
160
+ actor: User performing the action
161
+
162
+ Returns:
163
+ List[str]: List of agent IDs that have this source attached
164
+ """
165
+ async with db_registry.async_session() as session:
166
+ # Verify source exists and user has permission to access it
167
+ await self._validate_source_exists_async(session, source_id, actor)
168
+
169
+ # Query the junction table directly for performance
170
+ query = select(SourcesAgents.agent_id).where(SourcesAgents.source_id == source_id)
171
+
172
+ result = await session.execute(query)
173
+ agent_ids = result.scalars().all()
174
+
175
+ return list(agent_ids)
176
+
128
177
  # TODO: We make actor optional for now, but should most likely be enforced due to security reasons
129
178
  @enforce_types
130
179
  @trace_method
@@ -152,3 +201,63 @@ class SourceManager:
152
201
  return None
153
202
  else:
154
203
  return sources[0].to_pydantic()
204
+
205
+ @enforce_types
206
+ @trace_method
207
+ async def get_sources_by_ids_async(self, source_ids: List[str], actor: PydanticUser) -> List[PydanticSource]:
208
+ """
209
+ Get multiple sources by their IDs in a single query.
210
+
211
+ Args:
212
+ source_ids: List of source IDs to retrieve
213
+ actor: User performing the action
214
+
215
+ Returns:
216
+ List[PydanticSource]: List of sources (may be fewer than requested if some don't exist)
217
+ """
218
+ if not source_ids:
219
+ return []
220
+
221
+ async with db_registry.async_session() as session:
222
+ query = select(SourceModel).where(
223
+ SourceModel.id.in_(source_ids), SourceModel.organization_id == actor.organization_id, SourceModel.is_deleted == False
224
+ )
225
+
226
+ result = await session.execute(query)
227
+ sources_orm = result.scalars().all()
228
+
229
+ return [source.to_pydantic() for source in sources_orm]
230
+
231
+ @enforce_types
232
+ @trace_method
233
+ async def get_sources_for_agents_async(self, agent_ids: List[str], actor: PydanticUser) -> List[PydanticSource]:
234
+ """
235
+ Get all sources associated with the given agents via sources-agents relationships.
236
+
237
+ Args:
238
+ agent_ids: List of agent IDs to find sources for
239
+ actor: User performing the action
240
+
241
+ Returns:
242
+ List[PydanticSource]: List of unique sources associated with these agents
243
+ """
244
+ if not agent_ids:
245
+ return []
246
+
247
+ async with db_registry.async_session() as session:
248
+ # Join through sources-agents junction table
249
+ query = (
250
+ select(SourceModel)
251
+ .join(SourcesAgents, SourceModel.id == SourcesAgents.source_id)
252
+ .where(
253
+ SourcesAgents.agent_id.in_(agent_ids),
254
+ SourceModel.organization_id == actor.organization_id,
255
+ SourceModel.is_deleted == False,
256
+ )
257
+ .distinct() # Ensure we don't get duplicate sources
258
+ )
259
+
260
+ result = await session.execute(query)
261
+ sources_orm = result.scalars().all()
262
+
263
+ return [source.to_pydantic() for source in sources_orm]
@@ -12,6 +12,7 @@ from letta.orm.job import Job as JobModel
12
12
  from letta.orm.sqlalchemy_base import AccessType
13
13
  from letta.orm.step import Step as StepModel
14
14
  from letta.otel.tracing import get_trace_id, trace_method
15
+ from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
15
16
  from letta.schemas.openai.chat_completion_response import UsageStatistics
16
17
  from letta.schemas.step import Step as PydanticStep
17
18
  from letta.schemas.user import User as PydanticUser
@@ -131,6 +132,7 @@ class StepManager:
131
132
  job_id: Optional[str] = None,
132
133
  step_id: Optional[str] = None,
133
134
  project_id: Optional[str] = None,
135
+ stop_reason: Optional[LettaStopReason] = None,
134
136
  ) -> PydanticStep:
135
137
  step_data = {
136
138
  "origin": None,
@@ -153,12 +155,14 @@ class StepManager:
153
155
  }
154
156
  if step_id:
155
157
  step_data["id"] = step_id
158
+ if stop_reason:
159
+ step_data["stop_reason"] = stop_reason.stop_reason
156
160
  async with db_registry.async_session() as session:
157
- if job_id:
158
- await self._verify_job_access_async(session, job_id, actor, access=["write"])
159
161
  new_step = StepModel(**step_data)
160
- await new_step.create_async(session)
161
- return new_step.to_pydantic()
162
+ await new_step.create_async(session, no_commit=True, no_refresh=True)
163
+ pydantic_step = new_step.to_pydantic()
164
+ await session.commit()
165
+ return pydantic_step
162
166
 
163
167
  @enforce_types
164
168
  @trace_method
@@ -205,6 +209,33 @@ class StepManager:
205
209
  await session.commit()
206
210
  return step.to_pydantic()
207
211
 
212
+ @enforce_types
213
+ @trace_method
214
+ async def update_step_stop_reason(self, actor: PydanticUser, step_id: str, stop_reason: StopReasonType) -> PydanticStep:
215
+ """Update the stop reason for a step.
216
+
217
+ Args:
218
+ actor: The user making the request
219
+ step_id: The ID of the step to update
220
+ stop_reason: The stop reason to set
221
+
222
+ Returns:
223
+ The updated step
224
+
225
+ Raises:
226
+ NoResultFound: If the step does not exist
227
+ """
228
+ async with db_registry.async_session() as session:
229
+ step = await session.get(StepModel, step_id)
230
+ if not step:
231
+ raise NoResultFound(f"Step with id {step_id} does not exist")
232
+ if step.organization_id != actor.organization_id:
233
+ raise Exception("Unauthorized")
234
+
235
+ step.stop_reason = stop_reason
236
+ await session.commit()
237
+ return step
238
+
208
239
  def _verify_job_access(
209
240
  self,
210
241
  session: Session,
@@ -307,5 +338,6 @@ class NoopStepManager(StepManager):
307
338
  job_id: Optional[str] = None,
308
339
  step_id: Optional[str] = None,
309
340
  project_id: Optional[str] = None,
341
+ stop_reason: Optional[LettaStopReason] = None,
310
342
  ) -> PydanticStep:
311
343
  return
@@ -1,6 +1,7 @@
1
1
  from letta.helpers.json_helpers import json_dumps, json_loads
2
2
  from letta.helpers.singleton import singleton
3
3
  from letta.orm.provider_trace import ProviderTrace as ProviderTraceModel
4
+ from letta.otel.tracing import trace_method
4
5
  from letta.schemas.provider_trace import ProviderTrace as PydanticProviderTrace
5
6
  from letta.schemas.provider_trace import ProviderTraceCreate
6
7
  from letta.schemas.step import Step as PydanticStep
@@ -10,7 +11,9 @@ from letta.utils import enforce_types
10
11
 
11
12
 
12
13
  class TelemetryManager:
14
+
13
15
  @enforce_types
16
+ @trace_method
14
17
  async def get_provider_trace_by_step_id_async(
15
18
  self,
16
19
  step_id: str,
@@ -21,6 +24,7 @@ class TelemetryManager:
21
24
  return provider_trace.to_pydantic()
22
25
 
23
26
  @enforce_types
27
+ @trace_method
24
28
  async def create_provider_trace_async(self, actor: PydanticUser, provider_trace_create: ProviderTraceCreate) -> PydanticProviderTrace:
25
29
  async with db_registry.async_session() as session:
26
30
  provider_trace = ProviderTraceModel(**provider_trace_create.model_dump())
@@ -31,10 +35,13 @@ class TelemetryManager:
31
35
  if provider_trace_create.response_json:
32
36
  response_json_str = json_dumps(provider_trace_create.response_json)
33
37
  provider_trace.response_json = json_loads(response_json_str)
34
- await provider_trace.create_async(session, actor=actor)
35
- return provider_trace.to_pydantic()
38
+ await provider_trace.create_async(session, actor=actor, no_commit=True, no_refresh=True)
39
+ pydantic_provider_trace = provider_trace.to_pydantic()
40
+ await session.commit()
41
+ return pydantic_provider_trace
36
42
 
37
43
  @enforce_types
44
+ @trace_method
38
45
  def create_provider_trace(self, actor: PydanticUser, provider_trace_create: ProviderTraceCreate) -> PydanticProviderTrace:
39
46
  with db_registry.session() as session:
40
47
  provider_trace = ProviderTraceModel(**provider_trace_create.model_dump())
@@ -1,10 +1,12 @@
1
1
  import asyncio
2
2
  import json
3
+ import os
3
4
  import time
4
5
  from typing import Any, Dict, List, Literal, Optional
5
6
 
6
7
  from pydantic import BaseModel
7
8
 
9
+ from letta.constants import WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE, WEB_SEARCH_MODEL_ENV_VAR_NAME
8
10
  from letta.functions.prompts import FIRECRAWL_SEARCH_SYSTEM_PROMPT, get_firecrawl_search_user_prompt
9
11
  from letta.functions.types import SearchTask
10
12
  from letta.log import get_logger
@@ -322,8 +324,10 @@ class LettaBuiltinToolExecutor(ToolExecutor):
322
324
  # Time the OpenAI request
323
325
  start_time = time.time()
324
326
 
327
+ model = os.getenv(WEB_SEARCH_MODEL_ENV_VAR_NAME, WEB_SEARCH_MODEL_ENV_VAR_DEFAULT_VALUE)
328
+ logger.info(f"Using model {model} for web search result parsing")
325
329
  response = await client.beta.chat.completions.parse(
326
- model="gpt-4.1-mini-2025-04-14",
330
+ model=model,
327
331
  messages=[{"role": "system", "content": FIRECRAWL_SEARCH_SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}],
328
332
  response_format=DocumentAnalysis,
329
333
  temperature=0.1,
@@ -96,7 +96,7 @@ class LettaCoreToolExecutor(ToolExecutor):
96
96
  try:
97
97
  page = int(page)
98
98
  except:
99
- raise ValueError(f"'page' argument must be an integer")
99
+ raise ValueError("'page' argument must be an integer")
100
100
 
101
101
  count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
102
102
  messages = await MessageManager().list_user_messages_for_agent_async(
@@ -110,7 +110,7 @@ class LettaCoreToolExecutor(ToolExecutor):
110
110
  num_pages = math.ceil(total / count) - 1 # 0 index
111
111
 
112
112
  if len(messages) == 0:
113
- results_str = f"No results found."
113
+ results_str = "No results found."
114
114
  else:
115
115
  results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):"
116
116
  results_formatted = [message.content[0].text for message in messages]
@@ -137,7 +137,7 @@ class LettaCoreToolExecutor(ToolExecutor):
137
137
  try:
138
138
  page = int(page)
139
139
  except:
140
- raise ValueError(f"'page' argument must be an integer")
140
+ raise ValueError("'page' argument must be an integer")
141
141
 
142
142
  count = RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE
143
143
 
@@ -1,5 +1,5 @@
1
- import asyncio
2
1
  import importlib
2
+ import os
3
3
  import warnings
4
4
  from typing import List, Optional, Set, Union
5
5
 
@@ -14,6 +14,7 @@ from letta.constants import (
14
14
  FILES_TOOLS,
15
15
  LETTA_TOOL_MODULE_NAMES,
16
16
  LETTA_TOOL_SET,
17
+ LOCAL_ONLY_MULTI_AGENT_TOOLS,
17
18
  MCP_TOOL_TAG_NAME_PREFIX,
18
19
  )
19
20
  from letta.functions.functions import derive_openai_json_schema, load_function_set
@@ -30,6 +31,7 @@ from letta.schemas.user import User as PydanticUser
30
31
  from letta.server.db import db_registry
31
32
  from letta.services.helpers.agent_manager_helper import calculate_multi_agent_tools
32
33
  from letta.services.mcp.types import SSEServerConfig, StdioServerConfig
34
+ from letta.settings import settings
33
35
  from letta.utils import enforce_types, printd
34
36
 
35
37
  logger = get_logger(__name__)
@@ -74,6 +76,7 @@ class ToolManager:
74
76
  if tool_id:
75
77
  # Put to dict and remove fields that should not be reset
76
78
  update_data = pydantic_tool.model_dump(exclude_unset=True, exclude_none=True)
79
+ update_data["organization_id"] = actor.organization_id
77
80
 
78
81
  # If there's anything to update
79
82
  if update_data:
@@ -146,12 +149,12 @@ class ToolManager:
146
149
  def create_tool(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
147
150
  """Create a new tool based on the ToolCreate schema."""
148
151
  with db_registry.session() as session:
149
- # Set the organization id at the ORM layer
150
- pydantic_tool.organization_id = actor.organization_id
151
152
  # Auto-generate description if not provided
152
153
  if pydantic_tool.description is None:
153
154
  pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
154
155
  tool_data = pydantic_tool.model_dump(to_orm=True)
156
+ # Set the organization id at the ORM layer
157
+ tool_data["organization_id"] = actor.organization_id
155
158
 
156
159
  tool = ToolModel(**tool_data)
157
160
  tool.create(session, actor=actor) # Re-raise other database-related errors
@@ -162,12 +165,12 @@ class ToolManager:
162
165
  async def create_tool_async(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
163
166
  """Create a new tool based on the ToolCreate schema."""
164
167
  async with db_registry.async_session() as session:
165
- # Set the organization id at the ORM layer
166
- pydantic_tool.organization_id = actor.organization_id
167
168
  # Auto-generate description if not provided
168
169
  if pydantic_tool.description is None:
169
170
  pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
170
171
  tool_data = pydantic_tool.model_dump(to_orm=True)
172
+ # Set the organization id at the ORM layer
173
+ tool_data["organization_id"] = actor.organization_id
171
174
 
172
175
  tool = ToolModel(**tool_data)
173
176
  await tool.create_async(session, actor=actor) # Re-raise other database-related errors
@@ -250,7 +253,10 @@ class ToolManager:
250
253
  # TODO: This requires a deeper rethink about how we keep all our internal tools up-to-date
251
254
  if not after and upsert_base_tools:
252
255
  existing_tool_names = {tool.name for tool in tools}
253
- missing_base_tools = LETTA_TOOL_SET - existing_tool_names
256
+ base_tool_names = (
257
+ LETTA_TOOL_SET - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" else LETTA_TOOL_SET
258
+ )
259
+ missing_base_tools = base_tool_names - existing_tool_names
254
260
 
255
261
  # If any base tools are missing, upsert all base tools
256
262
  if missing_base_tools:
@@ -462,7 +468,10 @@ class ToolManager:
462
468
  actor: PydanticUser,
463
469
  allowed_types: Optional[Set[ToolType]] = None,
464
470
  ) -> List[PydanticTool]:
465
- """Add default tools defined in the various function_sets modules, optionally filtered by ToolType."""
471
+ """Add default tools defined in the various function_sets modules, optionally filtered by ToolType.
472
+
473
+ Optimized bulk implementation using single database session and batch operations.
474
+ """
466
475
 
467
476
  functions_to_schema = {}
468
477
  for module_name in LETTA_TOOL_MODULE_NAMES:
@@ -474,7 +483,8 @@ class ToolManager:
474
483
  except Exception as e:
475
484
  raise e
476
485
 
477
- tools = []
486
+ # prepare tool data for bulk operations
487
+ tool_data_list = []
478
488
  for name, schema in functions_to_schema.items():
479
489
  if name not in LETTA_TOOL_SET:
480
490
  continue
@@ -500,17 +510,82 @@ class ToolManager:
500
510
  if allowed_types is not None and tool_type not in allowed_types:
501
511
  continue
502
512
 
503
- tools.append(
504
- self.create_or_update_tool_async(
505
- PydanticTool(
506
- name=name,
507
- tags=[tool_type.value],
508
- source_type="python",
509
- tool_type=tool_type,
510
- return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
511
- ),
512
- actor=actor,
513
- )
513
+ # create pydantic tool for validation and conversion
514
+ pydantic_tool = PydanticTool(
515
+ name=name,
516
+ tags=[tool_type.value],
517
+ source_type="python",
518
+ tool_type=tool_type,
519
+ return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
514
520
  )
515
521
 
516
- return await asyncio.gather(*tools)
522
+ # auto-generate description if not provided
523
+ if pydantic_tool.description is None:
524
+ pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
525
+
526
+ tool_data_list.append(pydantic_tool)
527
+
528
+ if not tool_data_list:
529
+ return []
530
+
531
+ if settings.letta_pg_uri_no_default:
532
+ async with db_registry.async_session() as session:
533
+ return await self._bulk_upsert_postgresql(session, tool_data_list, actor)
534
+ else:
535
+ return await self._upsert_tools_individually(tool_data_list, actor)
536
+
537
+ @trace_method
538
+ async def _bulk_upsert_postgresql(self, session, tool_data_list: List[PydanticTool], actor: PydanticUser) -> List[PydanticTool]:
539
+ """hyper-optimized postgresql bulk upsert using on_conflict_do_update."""
540
+ from sqlalchemy import func, select
541
+ from sqlalchemy.dialects.postgresql import insert
542
+
543
+ # prepare data for bulk insert
544
+ table = ToolModel.__table__
545
+ valid_columns = {col.name for col in table.columns}
546
+
547
+ insert_data = []
548
+ for tool in tool_data_list:
549
+ tool_dict = tool.model_dump(to_orm=True)
550
+ # set created/updated by fields
551
+ if actor:
552
+ tool_dict["_created_by_id"] = actor.id
553
+ tool_dict["_last_updated_by_id"] = actor.id
554
+ tool_dict["organization_id"] = actor.organization_id
555
+
556
+ # filter to only include columns that exist in the table
557
+ filtered_dict = {k: v for k, v in tool_dict.items() if k in valid_columns}
558
+ insert_data.append(filtered_dict)
559
+
560
+ # use postgresql's native bulk upsert
561
+ stmt = insert(table).values(insert_data)
562
+
563
+ # on conflict, update all columns except id, created_at, and _created_by_id
564
+ excluded = stmt.excluded
565
+ update_dict = {}
566
+ for col in table.columns:
567
+ if col.name not in ("id", "created_at", "_created_by_id"):
568
+ if col.name == "updated_at":
569
+ update_dict[col.name] = func.now()
570
+ else:
571
+ update_dict[col.name] = excluded[col.name]
572
+
573
+ upsert_stmt = stmt.on_conflict_do_update(index_elements=["name", "organization_id"], set_=update_dict)
574
+
575
+ await session.execute(upsert_stmt)
576
+ await session.commit()
577
+
578
+ # fetch results
579
+ tool_names = [tool.name for tool in tool_data_list]
580
+ result_query = select(ToolModel).where(ToolModel.name.in_(tool_names), ToolModel.organization_id == actor.organization_id)
581
+ result = await session.execute(result_query)
582
+ return [tool.to_pydantic() for tool in result.scalars()]
583
+
584
+ @trace_method
585
+ async def _upsert_tools_individually(self, tool_data_list: List[PydanticTool], actor: PydanticUser) -> List[PydanticTool]:
586
+ """fallback to individual upserts for sqlite (original approach)."""
587
+ tools = []
588
+ for tool in tool_data_list:
589
+ upserted_tool = await self.create_or_update_tool_async(tool, actor)
590
+ tools.append(upserted_tool)
591
+ return tools
@@ -1,6 +1,6 @@
1
1
  from typing import List, Optional
2
2
 
3
- from sqlalchemy import select, text
3
+ from sqlalchemy import select
4
4
 
5
5
  from letta.constants import DEFAULT_ORG_ID
6
6
  from letta.data_sources.redis_client import get_redis_client
@@ -8,7 +8,6 @@ from letta.helpers.decorators import async_redis_cache
8
8
  from letta.log import get_logger
9
9
  from letta.orm.errors import NoResultFound
10
10
  from letta.orm.organization import Organization as OrganizationModel
11
- from letta.orm.sqlalchemy_base import is_postgresql_session
12
11
  from letta.orm.user import User as UserModel
13
12
  from letta.otel.tracing import trace_method
14
13
  from letta.schemas.user import User as PydanticUser
@@ -157,16 +156,9 @@ class UserManager:
157
156
  async def get_actor_by_id_async(self, actor_id: str) -> PydanticUser:
158
157
  """Fetch a user by ID asynchronously."""
159
158
  async with db_registry.async_session() as session:
160
- # Turn off seqscan to force use pk index
161
- if is_postgresql_session(session):
162
- await session.execute(text("SET LOCAL enable_seqscan = OFF"))
163
- try:
164
- stmt = select(UserModel).where(UserModel.id == actor_id)
165
- result = await session.execute(stmt)
166
- user = result.scalar_one_or_none()
167
- finally:
168
- if is_postgresql_session(session):
169
- await session.execute(text("SET LOCAL enable_seqscan = ON"))
159
+ stmt = select(UserModel).where(UserModel.id == actor_id)
160
+ result = await session.execute(stmt)
161
+ user = result.scalar_one_or_none()
170
162
 
171
163
  if not user:
172
164
  raise NoResultFound(f"User not found with id={actor_id}")
letta/settings.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import os
2
+ from enum import Enum
2
3
  from pathlib import Path
3
4
  from typing import Optional
4
5
 
@@ -182,6 +183,11 @@ if "--use-file-pg-uri" in sys.argv:
182
183
  pass
183
184
 
184
185
 
186
+ class DatabaseChoice(str, Enum):
187
+ POSTGRES = "postgres"
188
+ SQLITE = "sqlite"
189
+
190
+
185
191
  class Settings(BaseSettings):
186
192
  model_config = SettingsConfigDict(env_prefix="letta_", extra="ignore")
187
193
 
@@ -220,21 +226,24 @@ class Settings(BaseSettings):
220
226
  multi_agent_concurrent_sends: int = 50
221
227
 
222
228
  # telemetry logging
223
- otel_exporter_otlp_endpoint: Optional[str] = None # otel default: "http://localhost:4317"
224
- otel_preferred_temporality: Optional[int] = Field(
229
+ otel_exporter_otlp_endpoint: str | None = None # otel default: "http://localhost:4317"
230
+ otel_preferred_temporality: int | None = Field(
225
231
  default=1, ge=0, le=2, description="Exported metric temporality. {0: UNSPECIFIED, 1: DELTA, 2: CUMULATIVE}"
226
232
  )
227
233
  disable_tracing: bool = Field(default=False, description="Disable OTEL Tracing")
228
234
  llm_api_logging: bool = Field(default=True, description="Enable LLM API logging at each step")
229
235
  track_last_agent_run: bool = Field(default=False, description="Update last agent run metrics")
236
+ track_errored_messages: bool = Field(default=True, description="Enable tracking for errored messages")
237
+ track_stop_reason: bool = Field(default=True, description="Enable tracking stop reason on steps.")
238
+ track_agent_run: bool = Field(default=True, description="Enable tracking agent run with cancellation support")
230
239
 
231
- # uvicorn settings
240
+ # FastAPI Application Settings
232
241
  uvicorn_workers: int = 1
233
242
  uvicorn_reload: bool = False
234
243
  uvicorn_timeout_keep_alive: int = 5
235
244
 
236
- use_uvloop: bool = False
237
- use_granian: bool = False
245
+ use_uvloop: bool = Field(default=True, description="Enable uvloop as asyncio event loop.")
246
+ use_granian: bool = Field(default=False, description="Use Granian for workers")
238
247
  sqlalchemy_tracing: bool = False
239
248
 
240
249
  # event loop parallelism
@@ -244,6 +253,10 @@ class Settings(BaseSettings):
244
253
  use_experimental: bool = False
245
254
  use_vertex_structured_outputs_experimental: bool = False
246
255
 
256
+ # Database pool monitoring
257
+ enable_db_pool_monitoring: bool = True # Enable connection pool monitoring
258
+ db_pool_monitoring_interval: int = 30 # Seconds between pool stats collection
259
+
247
260
  # cron job parameters
248
261
  enable_batch_job_polling: bool = False
249
262
  poll_running_llm_batches_interval_seconds: int = 5 * 60
@@ -272,7 +285,7 @@ class Settings(BaseSettings):
272
285
  elif self.pg_db and self.pg_user and self.pg_password and self.pg_host and self.pg_port:
273
286
  return f"postgresql+pg8000://{self.pg_user}:{self.pg_password}@{self.pg_host}:{self.pg_port}/{self.pg_db}"
274
287
  else:
275
- return f"postgresql+pg8000://letta:letta@localhost:5432/letta"
288
+ return "postgresql+pg8000://letta:letta@localhost:5432/letta"
276
289
 
277
290
  # add this property to avoid being returned the default
278
291
  # reference: https://github.com/letta-ai/letta/issues/1362
@@ -285,6 +298,10 @@ class Settings(BaseSettings):
285
298
  else:
286
299
  return None
287
300
 
301
+ @property
302
+ def database_engine(self) -> DatabaseChoice:
303
+ return DatabaseChoice.POSTGRES if self.letta_pg_uri_no_default else DatabaseChoice.SQLITE
304
+
288
305
  @property
289
306
  def plugin_register_dict(self) -> dict:
290
307
  plugins = {}
letta/system.py CHANGED
@@ -66,7 +66,7 @@ def get_initial_boot_messages(version, timezone):
66
66
  "type": "function",
67
67
  "function": {
68
68
  "name": "send_message",
69
- "arguments": '{\n "message": "' + f"Hi, is anyone there?" + '"\n}',
69
+ "arguments": '{\n "message": "' + "Hi, is anyone there?" + '"\n}',
70
70
  },
71
71
  }
72
72
  ],