letta-nightly 0.7.29.dev20250602104315__py3-none-any.whl → 0.8.0.dev20250604104349__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 (138) hide show
  1. letta/__init__.py +7 -1
  2. letta/agent.py +16 -9
  3. letta/agents/base_agent.py +1 -0
  4. letta/agents/ephemeral_summary_agent.py +104 -0
  5. letta/agents/helpers.py +35 -3
  6. letta/agents/letta_agent.py +492 -176
  7. letta/agents/letta_agent_batch.py +22 -16
  8. letta/agents/prompts/summary_system_prompt.txt +62 -0
  9. letta/agents/voice_agent.py +22 -7
  10. letta/agents/voice_sleeptime_agent.py +13 -8
  11. letta/constants.py +33 -1
  12. letta/data_sources/connectors.py +52 -36
  13. letta/errors.py +4 -0
  14. letta/functions/ast_parsers.py +13 -30
  15. letta/functions/function_sets/base.py +3 -1
  16. letta/functions/functions.py +2 -0
  17. letta/functions/mcp_client/base_client.py +151 -97
  18. letta/functions/mcp_client/sse_client.py +49 -31
  19. letta/functions/mcp_client/stdio_client.py +107 -106
  20. letta/functions/schema_generator.py +22 -22
  21. letta/groups/helpers.py +3 -4
  22. letta/groups/sleeptime_multi_agent.py +4 -4
  23. letta/groups/sleeptime_multi_agent_v2.py +22 -0
  24. letta/helpers/composio_helpers.py +16 -0
  25. letta/helpers/converters.py +20 -0
  26. letta/helpers/datetime_helpers.py +1 -6
  27. letta/helpers/tool_rule_solver.py +2 -1
  28. letta/interfaces/anthropic_streaming_interface.py +17 -2
  29. letta/interfaces/openai_chat_completions_streaming_interface.py +1 -0
  30. letta/interfaces/openai_streaming_interface.py +18 -2
  31. letta/jobs/llm_batch_job_polling.py +1 -1
  32. letta/jobs/scheduler.py +1 -1
  33. letta/llm_api/anthropic_client.py +24 -3
  34. letta/llm_api/google_ai_client.py +0 -15
  35. letta/llm_api/google_vertex_client.py +6 -5
  36. letta/llm_api/llm_client_base.py +15 -0
  37. letta/llm_api/openai.py +2 -2
  38. letta/llm_api/openai_client.py +60 -8
  39. letta/orm/__init__.py +2 -0
  40. letta/orm/agent.py +45 -43
  41. letta/orm/base.py +0 -2
  42. letta/orm/block.py +1 -0
  43. letta/orm/custom_columns.py +13 -0
  44. letta/orm/enums.py +5 -0
  45. letta/orm/file.py +3 -1
  46. letta/orm/files_agents.py +68 -0
  47. letta/orm/mcp_server.py +48 -0
  48. letta/orm/message.py +1 -0
  49. letta/orm/organization.py +11 -2
  50. letta/orm/passage.py +25 -10
  51. letta/orm/sandbox_config.py +5 -2
  52. letta/orm/sqlalchemy_base.py +171 -110
  53. letta/prompts/system/memgpt_base.txt +6 -1
  54. letta/prompts/system/memgpt_v2_chat.txt +57 -0
  55. letta/prompts/system/sleeptime.txt +2 -0
  56. letta/prompts/system/sleeptime_v2.txt +28 -0
  57. letta/schemas/agent.py +87 -20
  58. letta/schemas/block.py +7 -1
  59. letta/schemas/file.py +57 -0
  60. letta/schemas/mcp.py +74 -0
  61. letta/schemas/memory.py +5 -2
  62. letta/schemas/message.py +9 -0
  63. letta/schemas/openai/openai.py +0 -6
  64. letta/schemas/providers.py +33 -4
  65. letta/schemas/tool.py +26 -21
  66. letta/schemas/tool_execution_result.py +5 -0
  67. letta/server/db.py +23 -8
  68. letta/server/rest_api/app.py +73 -56
  69. letta/server/rest_api/interface.py +4 -4
  70. letta/server/rest_api/routers/v1/agents.py +132 -47
  71. letta/server/rest_api/routers/v1/blocks.py +3 -2
  72. letta/server/rest_api/routers/v1/embeddings.py +3 -3
  73. letta/server/rest_api/routers/v1/groups.py +3 -3
  74. letta/server/rest_api/routers/v1/jobs.py +14 -17
  75. letta/server/rest_api/routers/v1/organizations.py +10 -10
  76. letta/server/rest_api/routers/v1/providers.py +12 -10
  77. letta/server/rest_api/routers/v1/runs.py +3 -3
  78. letta/server/rest_api/routers/v1/sandbox_configs.py +12 -12
  79. letta/server/rest_api/routers/v1/sources.py +108 -43
  80. letta/server/rest_api/routers/v1/steps.py +8 -6
  81. letta/server/rest_api/routers/v1/tools.py +134 -95
  82. letta/server/rest_api/utils.py +12 -1
  83. letta/server/server.py +272 -73
  84. letta/services/agent_manager.py +246 -313
  85. letta/services/block_manager.py +30 -9
  86. letta/services/context_window_calculator/__init__.py +0 -0
  87. letta/services/context_window_calculator/context_window_calculator.py +150 -0
  88. letta/services/context_window_calculator/token_counter.py +82 -0
  89. letta/services/file_processor/__init__.py +0 -0
  90. letta/services/file_processor/chunker/__init__.py +0 -0
  91. letta/services/file_processor/chunker/llama_index_chunker.py +29 -0
  92. letta/services/file_processor/embedder/__init__.py +0 -0
  93. letta/services/file_processor/embedder/openai_embedder.py +84 -0
  94. letta/services/file_processor/file_processor.py +123 -0
  95. letta/services/file_processor/parser/__init__.py +0 -0
  96. letta/services/file_processor/parser/base_parser.py +9 -0
  97. letta/services/file_processor/parser/mistral_parser.py +54 -0
  98. letta/services/file_processor/types.py +0 -0
  99. letta/services/files_agents_manager.py +184 -0
  100. letta/services/group_manager.py +118 -0
  101. letta/services/helpers/agent_manager_helper.py +76 -21
  102. letta/services/helpers/tool_execution_helper.py +3 -0
  103. letta/services/helpers/tool_parser_helper.py +100 -0
  104. letta/services/identity_manager.py +44 -42
  105. letta/services/job_manager.py +21 -10
  106. letta/services/mcp/base_client.py +5 -2
  107. letta/services/mcp/sse_client.py +3 -5
  108. letta/services/mcp/stdio_client.py +3 -5
  109. letta/services/mcp_manager.py +281 -0
  110. letta/services/message_manager.py +40 -26
  111. letta/services/organization_manager.py +55 -19
  112. letta/services/passage_manager.py +211 -13
  113. letta/services/provider_manager.py +48 -2
  114. letta/services/sandbox_config_manager.py +105 -0
  115. letta/services/source_manager.py +4 -5
  116. letta/services/step_manager.py +9 -6
  117. letta/services/summarizer/summarizer.py +50 -23
  118. letta/services/telemetry_manager.py +7 -0
  119. letta/services/tool_executor/tool_execution_manager.py +11 -52
  120. letta/services/tool_executor/tool_execution_sandbox.py +4 -34
  121. letta/services/tool_executor/tool_executor.py +107 -105
  122. letta/services/tool_manager.py +56 -17
  123. letta/services/tool_sandbox/base.py +39 -92
  124. letta/services/tool_sandbox/e2b_sandbox.py +16 -11
  125. letta/services/tool_sandbox/local_sandbox.py +51 -23
  126. letta/services/user_manager.py +36 -3
  127. letta/settings.py +10 -3
  128. letta/templates/__init__.py +0 -0
  129. letta/templates/sandbox_code_file.py.j2 +47 -0
  130. letta/templates/template_helper.py +16 -0
  131. letta/tracing.py +30 -1
  132. letta/types/__init__.py +7 -0
  133. letta/utils.py +25 -1
  134. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/METADATA +7 -2
  135. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/RECORD +138 -112
  136. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/LICENSE +0 -0
  137. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/WHEEL +0 -0
  138. {letta_nightly-0.7.29.dev20250602104315.dist-info → letta_nightly-0.8.0.dev20250604104349.dist-info}/entry_points.txt +0 -0
@@ -53,13 +53,13 @@ class AnthropicClient(LLMClientBase):
53
53
 
54
54
  @trace_method
55
55
  async def request_async(self, request_data: dict, llm_config: LLMConfig) -> dict:
56
- client = self._get_anthropic_client(llm_config, async_client=True)
56
+ client = await self._get_anthropic_client_async(llm_config, async_client=True)
57
57
  response = await client.beta.messages.create(**request_data, betas=["tools-2024-04-04"])
58
58
  return response.model_dump()
59
59
 
60
60
  @trace_method
61
61
  async def stream_async(self, request_data: dict, llm_config: LLMConfig) -> AsyncStream[BetaRawMessageStreamEvent]:
62
- client = self._get_anthropic_client(llm_config, async_client=True)
62
+ client = await self._get_anthropic_client_async(llm_config, async_client=True)
63
63
  request_data["stream"] = True
64
64
  return await client.beta.messages.create(**request_data, betas=["tools-2024-04-04"])
65
65
 
@@ -99,7 +99,7 @@ class AnthropicClient(LLMClientBase):
99
99
  for agent_id in agent_messages_mapping
100
100
  }
101
101
 
102
- client = self._get_anthropic_client(list(agent_llm_config_mapping.values())[0], async_client=True)
102
+ client = await self._get_anthropic_client_async(list(agent_llm_config_mapping.values())[0], async_client=True)
103
103
 
104
104
  anthropic_requests = [
105
105
  Request(custom_id=agent_id, params=MessageCreateParamsNonStreaming(**params)) for agent_id, params in requests.items()
@@ -134,6 +134,26 @@ class AnthropicClient(LLMClientBase):
134
134
  else anthropic.Anthropic(max_retries=model_settings.anthropic_max_retries)
135
135
  )
136
136
 
137
+ @trace_method
138
+ async def _get_anthropic_client_async(
139
+ self, llm_config: LLMConfig, async_client: bool = False
140
+ ) -> Union[anthropic.AsyncAnthropic, anthropic.Anthropic]:
141
+ override_key = None
142
+ if llm_config.provider_category == ProviderCategory.byok:
143
+ override_key = await ProviderManager().get_override_key_async(llm_config.provider_name, actor=self.actor)
144
+
145
+ if async_client:
146
+ return (
147
+ anthropic.AsyncAnthropic(api_key=override_key, max_retries=model_settings.anthropic_max_retries)
148
+ if override_key
149
+ else anthropic.AsyncAnthropic(max_retries=model_settings.anthropic_max_retries)
150
+ )
151
+ return (
152
+ anthropic.Anthropic(api_key=override_key, max_retries=model_settings.anthropic_max_retries)
153
+ if override_key
154
+ else anthropic.Anthropic(max_retries=model_settings.anthropic_max_retries)
155
+ )
156
+
137
157
  @trace_method
138
158
  def build_request_data(
139
159
  self,
@@ -268,6 +288,7 @@ class AnthropicClient(LLMClientBase):
268
288
  token_count -= 8
269
289
  return token_count
270
290
 
291
+ @trace_method
271
292
  def handle_llm_error(self, e: Exception) -> Exception:
272
293
  if isinstance(e, anthropic.APIConnectionError):
273
294
  logger.warning(f"[Anthropic] API connection error: {e.__cause__}")
@@ -7,10 +7,7 @@ from letta.errors import ErrorCode, LLMAuthenticationError, LLMError
7
7
  from letta.llm_api.google_constants import GOOGLE_MODEL_FOR_API_KEY_CHECK
8
8
  from letta.llm_api.google_vertex_client import GoogleVertexClient
9
9
  from letta.log import get_logger
10
- from letta.schemas.llm_config import LLMConfig
11
- from letta.schemas.message import Message as PydanticMessage
12
10
  from letta.settings import model_settings
13
- from letta.tracing import trace_method
14
11
 
15
12
  logger = get_logger(__name__)
16
13
 
@@ -20,18 +17,6 @@ class GoogleAIClient(GoogleVertexClient):
20
17
  def _get_client(self):
21
18
  return genai.Client(api_key=model_settings.gemini_api_key)
22
19
 
23
- @trace_method
24
- def build_request_data(
25
- self,
26
- messages: List[PydanticMessage],
27
- llm_config: LLMConfig,
28
- tools: List[dict],
29
- force_tool_call: Optional[str] = None,
30
- ) -> dict:
31
- request = super().build_request_data(messages, llm_config, tools, force_tool_call)
32
- del request["config"]["thinking_config"]
33
- return request
34
-
35
20
 
36
21
  def get_gemini_endpoint_and_headers(
37
22
  base_url: str, model: Optional[str], api_key: str, key_in_header: bool = True, generate_content: bool = False
@@ -241,13 +241,14 @@ class GoogleVertexClient(LLMClientBase):
241
241
  )
242
242
  request_data["config"]["tool_config"] = tool_config.model_dump()
243
243
 
244
- # Add thinking_config
244
+ # Add thinking_config for flash
245
245
  # If enable_reasoner is False, set thinking_budget to 0
246
246
  # Otherwise, use the value from max_reasoning_tokens
247
- thinking_config = ThinkingConfig(
248
- thinking_budget=llm_config.max_reasoning_tokens if llm_config.enable_reasoner else 0,
249
- )
250
- request_data["config"]["thinking_config"] = thinking_config.model_dump()
247
+ if "flash" in llm_config.model:
248
+ thinking_config = ThinkingConfig(
249
+ thinking_budget=llm_config.max_reasoning_tokens if llm_config.enable_reasoner else 0,
250
+ )
251
+ request_data["config"]["thinking_config"] = thinking_config.model_dump()
251
252
 
252
253
  return request_data
253
254
 
@@ -6,6 +6,7 @@ from openai import AsyncStream, Stream
6
6
  from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
7
7
 
8
8
  from letta.errors import LLMError
9
+ from letta.schemas.embedding_config import EmbeddingConfig
9
10
  from letta.schemas.llm_config import LLMConfig
10
11
  from letta.schemas.message import Message
11
12
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
@@ -138,6 +139,20 @@ class LLMClientBase:
138
139
  """
139
140
  raise NotImplementedError
140
141
 
142
+ @abstractmethod
143
+ async def request_embeddings(self, texts: List[str], embedding_config: EmbeddingConfig) -> List[List[float]]:
144
+ """
145
+ Generate embeddings for a batch of texts.
146
+
147
+ Args:
148
+ texts (List[str]): List of texts to generate embeddings for.
149
+ embedding_config (EmbeddingConfig): Configuration for the embedding model.
150
+
151
+ Returns:
152
+ embeddings (List[List[float]]): List of embeddings for the input texts.
153
+ """
154
+ raise NotImplementedError
155
+
141
156
  @abstractmethod
142
157
  def convert_response_to_chat_completion(
143
158
  self,
letta/llm_api/openai.py CHANGED
@@ -226,7 +226,7 @@ def build_openai_chat_completions_request(
226
226
  tool_choice=tool_choice,
227
227
  user=str(user_id),
228
228
  max_completion_tokens=llm_config.max_tokens,
229
- temperature=llm_config.temperature if supports_temperature_param(model) else None,
229
+ temperature=llm_config.temperature if supports_temperature_param(model) else 1.0,
230
230
  reasoning_effort=llm_config.reasoning_effort,
231
231
  )
232
232
  else:
@@ -237,7 +237,7 @@ def build_openai_chat_completions_request(
237
237
  function_call=function_call,
238
238
  user=str(user_id),
239
239
  max_completion_tokens=llm_config.max_tokens,
240
- temperature=1.0 if llm_config.enable_reasoner else llm_config.temperature,
240
+ temperature=llm_config.temperature if supports_temperature_param(model) else 1.0,
241
241
  reasoning_effort=llm_config.reasoning_effort,
242
242
  )
243
243
  # https://platform.openai.com/docs/guides/text-generation/json-mode
@@ -12,6 +12,7 @@ from letta.errors import (
12
12
  LLMAuthenticationError,
13
13
  LLMBadRequestError,
14
14
  LLMConnectionError,
15
+ LLMContextWindowExceededError,
15
16
  LLMNotFoundError,
16
17
  LLMPermissionDeniedError,
17
18
  LLMRateLimitError,
@@ -22,6 +23,7 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions, convert_to_st
22
23
  from letta.llm_api.llm_client_base import LLMClientBase
23
24
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
24
25
  from letta.log import get_logger
26
+ from letta.schemas.embedding_config import EmbeddingConfig
25
27
  from letta.schemas.enums import ProviderCategory, ProviderType
26
28
  from letta.schemas.llm_config import LLMConfig
27
29
  from letta.schemas.message import Message as PydanticMessage
@@ -125,6 +127,35 @@ class OpenAIClient(LLMClientBase):
125
127
 
126
128
  return kwargs
127
129
 
130
+ def _prepare_client_kwargs_embedding(self, embedding_config: EmbeddingConfig) -> dict:
131
+ api_key = None
132
+ if embedding_config.embedding_endpoint_type == ProviderType.together:
133
+ api_key = model_settings.together_api_key or os.environ.get("TOGETHER_API_KEY")
134
+
135
+ if not api_key:
136
+ api_key = model_settings.openai_api_key or os.environ.get("OPENAI_API_KEY")
137
+ # supposedly the openai python client requires a dummy API key
138
+ api_key = api_key or "DUMMY_API_KEY"
139
+ kwargs = {"api_key": api_key, "base_url": embedding_config.embedding_endpoint}
140
+ return kwargs
141
+
142
+ async def _prepare_client_kwargs_async(self, llm_config: LLMConfig) -> dict:
143
+ api_key = None
144
+ if llm_config.provider_category == ProviderCategory.byok:
145
+ from letta.services.provider_manager import ProviderManager
146
+
147
+ api_key = await ProviderManager().get_override_key_async(llm_config.provider_name, actor=self.actor)
148
+ if llm_config.model_endpoint_type == ProviderType.together:
149
+ api_key = model_settings.together_api_key or os.environ.get("TOGETHER_API_KEY")
150
+
151
+ if not api_key:
152
+ api_key = model_settings.openai_api_key or os.environ.get("OPENAI_API_KEY")
153
+ # supposedly the openai python client requires a dummy API key
154
+ api_key = api_key or "DUMMY_API_KEY"
155
+ kwargs = {"api_key": api_key, "base_url": llm_config.model_endpoint}
156
+
157
+ return kwargs
158
+
128
159
  @trace_method
129
160
  def build_request_data(
130
161
  self,
@@ -190,7 +221,6 @@ class OpenAIClient(LLMClientBase):
190
221
  # NOTE: the reasoners that don't support temperature require 1.0, not None
191
222
  temperature=llm_config.temperature if supports_temperature_param(model) else 1.0,
192
223
  )
193
-
194
224
  # always set user id for openai requests
195
225
  if self.actor:
196
226
  data.user = self.actor.id
@@ -231,7 +261,8 @@ class OpenAIClient(LLMClientBase):
231
261
  """
232
262
  Performs underlying asynchronous request to OpenAI API and returns raw response dict.
233
263
  """
234
- client = AsyncOpenAI(**self._prepare_client_kwargs(llm_config))
264
+ kwargs = await self._prepare_client_kwargs_async(llm_config)
265
+ client = AsyncOpenAI(**kwargs)
235
266
  response: ChatCompletion = await client.chat.completions.create(**request_data)
236
267
  return response.model_dump()
237
268
 
@@ -262,16 +293,29 @@ class OpenAIClient(LLMClientBase):
262
293
 
263
294
  return chat_completion_response
264
295
 
296
+ @trace_method
265
297
  async def stream_async(self, request_data: dict, llm_config: LLMConfig) -> AsyncStream[ChatCompletionChunk]:
266
298
  """
267
299
  Performs underlying asynchronous streaming request to OpenAI and returns the async stream iterator.
268
300
  """
269
- client = AsyncOpenAI(**self._prepare_client_kwargs(llm_config))
301
+ kwargs = await self._prepare_client_kwargs_async(llm_config)
302
+ client = AsyncOpenAI(**kwargs)
270
303
  response_stream: AsyncStream[ChatCompletionChunk] = await client.chat.completions.create(
271
304
  **request_data, stream=True, stream_options={"include_usage": True}
272
305
  )
273
306
  return response_stream
274
307
 
308
+ @trace_method
309
+ async def request_embeddings(self, inputs: List[str], embedding_config: EmbeddingConfig) -> List[dict]:
310
+ """Request embeddings given texts and embedding config"""
311
+ kwargs = self._prepare_client_kwargs_embedding(embedding_config)
312
+ client = AsyncOpenAI(**kwargs)
313
+ response = await client.embeddings.create(model=embedding_config.embedding_model, input=inputs)
314
+
315
+ # TODO: add total usage
316
+ return [r.embedding for r in response.data]
317
+
318
+ @trace_method
275
319
  def handle_llm_error(self, e: Exception) -> Exception:
276
320
  """
277
321
  Maps OpenAI-specific errors to common LLMError types.
@@ -297,11 +341,19 @@ class OpenAIClient(LLMClientBase):
297
341
  # BadRequestError can signify different issues (e.g., invalid args, context length)
298
342
  # Check message content if finer-grained errors are needed
299
343
  # Example: if "context_length_exceeded" in str(e): return LLMContextLengthExceededError(...)
300
- return LLMBadRequestError(
301
- message=f"Bad request to OpenAI: {str(e)}",
302
- code=ErrorCode.INVALID_ARGUMENT, # Or more specific if detectable
303
- details=e.body,
304
- )
344
+ # TODO: This is a super soft check. Not sure if we can do better, needs more investigation.
345
+ if "context" in str(e):
346
+ return LLMContextWindowExceededError(
347
+ message=f"Bad request to OpenAI (context length exceeded): {str(e)}",
348
+ code=ErrorCode.INVALID_ARGUMENT, # Or more specific if detectable
349
+ details=e.body,
350
+ )
351
+ else:
352
+ return LLMBadRequestError(
353
+ message=f"Bad request to OpenAI: {str(e)}",
354
+ code=ErrorCode.INVALID_ARGUMENT, # Or more specific if detectable
355
+ details=e.body,
356
+ )
305
357
 
306
358
  if isinstance(e, openai.AuthenticationError):
307
359
  logger.error(f"[OpenAI] Authentication error (401): {str(e)}") # More severe log level
letta/orm/__init__.py CHANGED
@@ -5,6 +5,7 @@ from letta.orm.block import Block
5
5
  from letta.orm.block_history import BlockHistory
6
6
  from letta.orm.blocks_agents import BlocksAgents
7
7
  from letta.orm.file import FileMetadata
8
+ from letta.orm.files_agents import FileAgent
8
9
  from letta.orm.group import Group
9
10
  from letta.orm.groups_agents import GroupsAgents
10
11
  from letta.orm.groups_blocks import GroupsBlocks
@@ -15,6 +16,7 @@ from letta.orm.job import Job
15
16
  from letta.orm.job_messages import JobMessage
16
17
  from letta.orm.llm_batch_items import LLMBatchItem
17
18
  from letta.orm.llm_batch_job import LLMBatchJob
19
+ from letta.orm.mcp_server import MCPServer
18
20
  from letta.orm.message import Message
19
21
  from letta.orm.organization import Organization
20
22
  from letta.orm.passage import AgentPassage, BasePassage, SourcePassage
letta/orm/agent.py CHANGED
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import uuid
2
3
  from typing import TYPE_CHECKING, List, Optional, Set
3
4
 
@@ -21,6 +22,7 @@ from letta.schemas.tool_rule import ToolRule
21
22
 
22
23
  if TYPE_CHECKING:
23
24
  from letta.orm.agents_tags import AgentsTags
25
+ from letta.orm.files_agents import FileAgent
24
26
  from letta.orm.identity import Identity
25
27
  from letta.orm.organization import Organization
26
28
  from letta.orm.source import Source
@@ -125,6 +127,12 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
125
127
  back_populates="manager_agent",
126
128
  )
127
129
  batch_items: Mapped[List["LLMBatchItem"]] = relationship("LLMBatchItem", back_populates="agent", lazy="selectin")
130
+ file_agents: Mapped[List["FileAgent"]] = relationship(
131
+ "FileAgent",
132
+ back_populates="agent",
133
+ cascade="all, delete-orphan",
134
+ lazy="selectin",
135
+ )
128
136
 
129
137
  def to_pydantic(self, include_relationships: Optional[Set[str]] = None) -> PydanticAgentState:
130
138
  """
@@ -166,6 +174,8 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
166
174
  "last_updated_by_id": self.last_updated_by_id,
167
175
  "created_at": self.created_at,
168
176
  "updated_at": self.updated_at,
177
+ "enable_sleeptime": self.enable_sleeptime,
178
+ "response_format": self.response_format,
169
179
  # optional field defaults
170
180
  "tags": [],
171
181
  "tools": [],
@@ -174,8 +184,6 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
174
184
  "identity_ids": [],
175
185
  "multi_agent_group": None,
176
186
  "tool_exec_environment_variables": [],
177
- "enable_sleeptime": None,
178
- "response_format": self.response_format,
179
187
  }
180
188
 
181
189
  # Optional fields: only included if requested
@@ -185,12 +193,12 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
185
193
  "sources": lambda: [s.to_pydantic() for s in self.sources],
186
194
  "memory": lambda: Memory(
187
195
  blocks=[b.to_pydantic() for b in self.core_memory],
196
+ file_blocks=[block for b in self.file_agents if (block := b.to_pydantic_block()) is not None],
188
197
  prompt_template=get_prompt_template_for_agent_type(self.agent_type),
189
198
  ),
190
199
  "identity_ids": lambda: [i.id for i in self.identities],
191
200
  "multi_agent_group": lambda: self.multi_agent_group,
192
201
  "tool_exec_environment_variables": lambda: self.tool_exec_environment_variables,
193
- "enable_sleeptime": lambda: self.enable_sleeptime,
194
202
  }
195
203
 
196
204
  include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships)
@@ -242,15 +250,7 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
242
250
  "last_updated_by_id": self.last_updated_by_id,
243
251
  "created_at": self.created_at,
244
252
  "updated_at": self.updated_at,
245
- # optional field defaults
246
- "tags": [],
247
- "tools": [],
248
- "sources": [],
249
- "memory": Memory(blocks=[]),
250
- "identity_ids": [],
251
- "multi_agent_group": None,
252
- "tool_exec_environment_variables": [],
253
- "enable_sleeptime": None,
253
+ "enable_sleeptime": self.enable_sleeptime,
254
254
  "response_format": self.response_format,
255
255
  }
256
256
  optional_fields = {
@@ -261,43 +261,45 @@ class Agent(SqlalchemyBase, OrganizationMixin, AsyncAttrs):
261
261
  "identity_ids": [],
262
262
  "multi_agent_group": None,
263
263
  "tool_exec_environment_variables": [],
264
- "enable_sleeptime": None,
265
- "response_format": self.response_format,
266
264
  }
267
265
 
268
266
  # Initialize include_relationships to an empty set if it's None
269
267
  include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships)
270
268
 
271
- # Only load requested relationships
272
- if "tags" in include_relationships:
273
- tags = await self.awaitable_attrs.tags
274
- state["tags"] = [t.tag for t in tags]
275
-
276
- if "tools" in include_relationships:
277
- state["tools"] = await self.awaitable_attrs.tools
269
+ async def empty_list_async():
270
+ return []
278
271
 
279
- if "sources" in include_relationships:
280
- sources = await self.awaitable_attrs.sources
281
- state["sources"] = [s.to_pydantic() for s in sources]
272
+ async def none_async():
273
+ return None
282
274
 
283
- if "memory" in include_relationships:
284
- memory_blocks = await self.awaitable_attrs.core_memory
285
- state["memory"] = Memory(
286
- blocks=[b.to_pydantic() for b in memory_blocks],
287
- prompt_template=get_prompt_template_for_agent_type(self.agent_type),
288
- )
289
-
290
- if "identity_ids" in include_relationships:
291
- identities = await self.awaitable_attrs.identities
292
- state["identity_ids"] = [i.id for i in identities]
293
-
294
- if "multi_agent_group" in include_relationships:
295
- state["multi_agent_group"] = await self.awaitable_attrs.multi_agent_group
296
-
297
- if "tool_exec_environment_variables" in include_relationships:
298
- state["tool_exec_environment_variables"] = await self.awaitable_attrs.tool_exec_environment_variables
299
-
300
- if "enable_sleeptime" in include_relationships:
301
- state["enable_sleeptime"] = await self.awaitable_attrs.enable_sleeptime
275
+ # Only load requested relationships
276
+ tags = self.awaitable_attrs.tags if "tags" in include_relationships else empty_list_async()
277
+ tools = self.awaitable_attrs.tools if "tools" in include_relationships else empty_list_async()
278
+ sources = self.awaitable_attrs.sources if "sources" in include_relationships else empty_list_async()
279
+ memory = self.awaitable_attrs.core_memory if "memory" in include_relationships else empty_list_async()
280
+ identities = self.awaitable_attrs.identities if "identity_ids" in include_relationships else empty_list_async()
281
+ multi_agent_group = self.awaitable_attrs.multi_agent_group if "multi_agent_group" in include_relationships else none_async()
282
+ tool_exec_environment_variables = (
283
+ self.awaitable_attrs.tool_exec_environment_variables
284
+ if "tool_exec_environment_variables" in include_relationships
285
+ else empty_list_async()
286
+ )
287
+ file_agents = self.awaitable_attrs.file_agents if "memory" in include_relationships else empty_list_async()
288
+
289
+ (tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents) = await asyncio.gather(
290
+ tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents
291
+ )
292
+
293
+ state["tags"] = [t.tag for t in tags]
294
+ state["tools"] = [t.to_pydantic() for t in tools]
295
+ state["sources"] = [s.to_pydantic() for s in sources]
296
+ state["memory"] = Memory(
297
+ blocks=[m.to_pydantic() for m in memory],
298
+ file_blocks=[block for b in self.file_agents if (block := b.to_pydantic_block()) is not None],
299
+ prompt_template=get_prompt_template_for_agent_type(self.agent_type),
300
+ )
301
+ state["identity_ids"] = [i.id for i in identities]
302
+ state["multi_agent_group"] = multi_agent_group
303
+ state["tool_exec_environment_variables"] = tool_exec_environment_variables
302
304
 
303
305
  return self.__pydantic_model__(**state)
letta/orm/base.py CHANGED
@@ -69,8 +69,6 @@ class CommonSqlalchemyMetaMixins(Base):
69
69
  """returns the user id for the specified property"""
70
70
  full_prop = f"_{prop}_by_id"
71
71
  prop_value = getattr(self, full_prop, None)
72
- if not prop_value:
73
- return
74
72
  return prop_value
75
73
 
76
74
  def _user_id_setter(self, prop: str, value: str) -> None:
letta/orm/block.py CHANGED
@@ -35,6 +35,7 @@ class Block(OrganizationMixin, SqlalchemyBase):
35
35
  is_template: Mapped[bool] = mapped_column(
36
36
  doc="whether the block is a template (e.g. saved human/persona options as baselines for other templates)", default=False
37
37
  )
38
+ preserve_on_migration: Mapped[Optional[bool]] = mapped_column(doc="preserve the block on template migration", default=False)
38
39
  value: Mapped[str] = mapped_column(doc="Text content of the block for the respective section of core memory.")
39
40
  limit: Mapped[BigInteger] = mapped_column(Integer, default=CORE_MEMORY_BLOCK_CHAR_LIMIT, doc="Character limit of the block.")
40
41
  metadata_: Mapped[Optional[dict]] = mapped_column(JSON, default={}, doc="arbitrary information related to the block.")
@@ -7,6 +7,7 @@ from letta.helpers.converters import (
7
7
  deserialize_create_batch_response,
8
8
  deserialize_embedding_config,
9
9
  deserialize_llm_config,
10
+ deserialize_mcp_stdio_config,
10
11
  deserialize_message_content,
11
12
  deserialize_poll_batch_response,
12
13
  deserialize_response_format,
@@ -19,6 +20,7 @@ from letta.helpers.converters import (
19
20
  serialize_create_batch_response,
20
21
  serialize_embedding_config,
21
22
  serialize_llm_config,
23
+ serialize_mcp_stdio_config,
22
24
  serialize_message_content,
23
25
  serialize_poll_batch_response,
24
26
  serialize_response_format,
@@ -183,3 +185,14 @@ class ResponseFormatColumn(TypeDecorator):
183
185
 
184
186
  def process_result_value(self, value, dialect):
185
187
  return deserialize_response_format(value)
188
+
189
+
190
+ class MCPStdioServerConfigColumn(TypeDecorator):
191
+ impl = JSON
192
+ cache_ok = True
193
+
194
+ def process_bind_param(self, value, dialect):
195
+ return serialize_mcp_stdio_config(value)
196
+
197
+ def process_result_value(self, value, dialect):
198
+ return deserialize_mcp_stdio_config(value)
letta/orm/enums.py CHANGED
@@ -32,3 +32,8 @@ class ActorType(str, Enum):
32
32
  LETTA_USER = "letta_user"
33
33
  LETTA_AGENT = "letta_agent"
34
34
  LETTA_SYSTEM = "letta_system"
35
+
36
+
37
+ class MCPServerType(str, Enum):
38
+ SSE = "sse"
39
+ STDIO = "stdio"
letta/orm/file.py CHANGED
@@ -8,13 +8,14 @@ from letta.orm.sqlalchemy_base import SqlalchemyBase
8
8
  from letta.schemas.file import FileMetadata as PydanticFileMetadata
9
9
 
10
10
  if TYPE_CHECKING:
11
+ from letta.orm.files_agents import FileAgent
11
12
  from letta.orm.organization import Organization
12
13
  from letta.orm.passage import SourcePassage
13
14
  from letta.orm.source import Source
14
15
 
15
16
 
16
17
  class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin):
17
- """Represents metadata for an uploaded file."""
18
+ """Represents an uploaded file."""
18
19
 
19
20
  __tablename__ = "files"
20
21
  __pydantic_model__ = PydanticFileMetadata
@@ -32,3 +33,4 @@ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin):
32
33
  source_passages: Mapped[List["SourcePassage"]] = relationship(
33
34
  "SourcePassage", back_populates="file", lazy="selectin", cascade="all, delete-orphan"
34
35
  )
36
+ file_agents: Mapped[List["FileAgent"]] = relationship("FileAgent", back_populates="file", lazy="selectin")
@@ -0,0 +1,68 @@
1
+ import uuid
2
+ from datetime import datetime
3
+ from typing import TYPE_CHECKING, Optional
4
+
5
+ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from letta.orm.mixins import OrganizationMixin
9
+ from letta.orm.sqlalchemy_base import SqlalchemyBase
10
+ from letta.schemas.block import Block as PydanticBlock
11
+ from letta.schemas.file import FileAgent as PydanticFileAgent
12
+
13
+ if TYPE_CHECKING:
14
+ from letta.orm.file import FileMetadata
15
+
16
+
17
+ class FileAgent(SqlalchemyBase, OrganizationMixin):
18
+ """
19
+ Join table between File and Agent.
20
+
21
+ Tracks whether a file is currently “open” for the agent and
22
+ the specific excerpt (grepped section) the agent is looking at.
23
+ """
24
+
25
+ __tablename__ = "files_agents"
26
+ __table_args__ = (
27
+ Index("ix_files_agents_file_id_agent_id", "file_id", "agent_id"),
28
+ UniqueConstraint("file_id", "agent_id", name="uq_files_agents_file_agent"),
29
+ )
30
+ __pydantic_model__ = PydanticFileAgent
31
+
32
+ # TODO: We want to migrate all the ORM models to do this, so we will need to move this to the SqlalchemyBase
33
+ # TODO: Some still rely on the Pydantic object to do this
34
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"file_agent-{uuid.uuid4()}")
35
+ file_id: Mapped[str] = mapped_column(String, ForeignKey("files.id", ondelete="CASCADE"), primary_key=True, doc="ID of the file.")
36
+ agent_id: Mapped[str] = mapped_column(String, ForeignKey("agents.id", ondelete="CASCADE"), primary_key=True, doc="ID of the agent.")
37
+
38
+ is_open: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, doc="True if the agent currently has the file open.")
39
+ visible_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True, doc="Portion of the file the agent is focused on.")
40
+ last_accessed_at: Mapped[datetime] = mapped_column(
41
+ DateTime(timezone=True),
42
+ server_default=func.now(),
43
+ onupdate=func.now(),
44
+ nullable=False,
45
+ doc="UTC timestamp when this agent last accessed the file.",
46
+ )
47
+
48
+ # relationships
49
+ agent: Mapped["Agent"] = relationship(
50
+ "Agent",
51
+ back_populates="file_agents",
52
+ lazy="selectin",
53
+ )
54
+ file: Mapped["FileMetadata"] = relationship(
55
+ "FileMetadata",
56
+ foreign_keys=[file_id],
57
+ lazy="selectin",
58
+ )
59
+
60
+ # TODO: This is temporary as we figure out if we want FileBlock as a first class citizen
61
+ def to_pydantic_block(self) -> PydanticBlock:
62
+ visible_content = self.visible_content if self.visible_content and self.is_open else ""
63
+ return PydanticBlock(
64
+ organization_id=self.organization_id,
65
+ value=visible_content,
66
+ label=self.file.file_name,
67
+ read_only=True,
68
+ )
@@ -0,0 +1,48 @@
1
+ from typing import TYPE_CHECKING, Optional
2
+
3
+ from sqlalchemy import JSON, String, UniqueConstraint
4
+ from sqlalchemy.orm import Mapped, mapped_column
5
+
6
+ from letta.functions.mcp_client.types import StdioServerConfig
7
+ from letta.orm.custom_columns import MCPStdioServerConfigColumn
8
+
9
+ # TODO everything in functions should live in this model
10
+ from letta.orm.enums import MCPServerType
11
+ from letta.orm.mixins import OrganizationMixin
12
+ from letta.orm.sqlalchemy_base import SqlalchemyBase
13
+ from letta.schemas.mcp import MCPServer
14
+
15
+ if TYPE_CHECKING:
16
+ pass
17
+
18
+
19
+ class MCPServer(SqlalchemyBase, OrganizationMixin):
20
+ """Represents a registered MCP server"""
21
+
22
+ __tablename__ = "mcp_server"
23
+ __pydantic_model__ = MCPServer
24
+
25
+ # Add unique constraint on (name, _organization_id)
26
+ # An organization should not have multiple tools with the same name
27
+ __table_args__ = (UniqueConstraint("server_name", "organization_id", name="uix_name_organization_mcp_server"),)
28
+
29
+ server_name: Mapped[str] = mapped_column(doc="The display name of the MCP server")
30
+ server_type: Mapped[MCPServerType] = mapped_column(
31
+ String, default=MCPServerType.SSE, doc="The type of the MCP server. Only SSE is supported for remote servers."
32
+ )
33
+
34
+ # sse server
35
+ server_url: Mapped[Optional[str]] = mapped_column(
36
+ String, nullable=True, doc="The URL of the server (MCP SSE client will connect to this URL)"
37
+ )
38
+
39
+ # stdio server
40
+ stdio_config: Mapped[Optional[StdioServerConfig]] = mapped_column(
41
+ MCPStdioServerConfigColumn, nullable=True, doc="The configuration for the stdio server"
42
+ )
43
+
44
+ metadata_: Mapped[Optional[dict]] = mapped_column(
45
+ JSON, default=lambda: {}, doc="A dictionary of additional metadata for the MCP server."
46
+ )
47
+ # relationships
48
+ # organization: Mapped["Organization"] = relationship("Organization", back_populates="mcp_server", lazy="selectin")
letta/orm/message.py CHANGED
@@ -22,6 +22,7 @@ class Message(SqlalchemyBase, OrganizationMixin, AgentMixin):
22
22
  Index("ix_messages_agent_created_at", "agent_id", "created_at"),
23
23
  Index("ix_messages_created_at", "created_at", "id"),
24
24
  Index("ix_messages_agent_sequence", "agent_id", "sequence_id"),
25
+ Index("ix_messages_org_agent", "organization_id", "agent_id"),
25
26
  )
26
27
  __pydantic_model__ = PydanticMessage
27
28