letta-nightly 0.8.0.dev20250606195656__py3-none-any.whl → 0.8.3.dev20250607000559__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 (105) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +16 -12
  3. letta/agents/base_agent.py +1 -1
  4. letta/agents/helpers.py +13 -2
  5. letta/agents/letta_agent.py +72 -34
  6. letta/agents/letta_agent_batch.py +1 -2
  7. letta/agents/voice_agent.py +19 -13
  8. letta/agents/voice_sleeptime_agent.py +23 -6
  9. letta/constants.py +18 -0
  10. letta/data_sources/__init__.py +0 -0
  11. letta/data_sources/redis_client.py +282 -0
  12. letta/errors.py +0 -4
  13. letta/functions/function_sets/files.py +58 -0
  14. letta/functions/schema_generator.py +18 -1
  15. letta/groups/sleeptime_multi_agent_v2.py +13 -3
  16. letta/helpers/datetime_helpers.py +47 -3
  17. letta/helpers/decorators.py +69 -0
  18. letta/{services/helpers/noop_helper.py → helpers/singleton.py} +5 -0
  19. letta/interfaces/anthropic_streaming_interface.py +43 -24
  20. letta/interfaces/openai_streaming_interface.py +21 -19
  21. letta/llm_api/anthropic.py +1 -1
  22. letta/llm_api/anthropic_client.py +30 -16
  23. letta/llm_api/google_vertex_client.py +1 -1
  24. letta/llm_api/helpers.py +36 -30
  25. letta/llm_api/llm_api_tools.py +1 -1
  26. letta/llm_api/llm_client_base.py +29 -1
  27. letta/llm_api/openai.py +1 -1
  28. letta/llm_api/openai_client.py +6 -8
  29. letta/local_llm/chat_completion_proxy.py +1 -1
  30. letta/memory.py +1 -1
  31. letta/orm/enums.py +1 -0
  32. letta/orm/file.py +80 -3
  33. letta/orm/files_agents.py +13 -0
  34. letta/orm/passage.py +2 -0
  35. letta/orm/sqlalchemy_base.py +34 -11
  36. letta/otel/__init__.py +0 -0
  37. letta/otel/context.py +25 -0
  38. letta/otel/events.py +0 -0
  39. letta/otel/metric_registry.py +122 -0
  40. letta/otel/metrics.py +66 -0
  41. letta/otel/resource.py +26 -0
  42. letta/{tracing.py → otel/tracing.py} +55 -78
  43. letta/plugins/README.md +22 -0
  44. letta/plugins/__init__.py +0 -0
  45. letta/plugins/defaults.py +11 -0
  46. letta/plugins/plugins.py +72 -0
  47. letta/schemas/enums.py +8 -0
  48. letta/schemas/file.py +12 -0
  49. letta/schemas/letta_request.py +6 -0
  50. letta/schemas/passage.py +1 -0
  51. letta/schemas/tool.py +4 -0
  52. letta/server/db.py +7 -7
  53. letta/server/rest_api/app.py +8 -6
  54. letta/server/rest_api/routers/v1/agents.py +46 -37
  55. letta/server/rest_api/routers/v1/groups.py +3 -3
  56. letta/server/rest_api/routers/v1/sources.py +26 -3
  57. letta/server/rest_api/routers/v1/tools.py +7 -2
  58. letta/server/rest_api/utils.py +9 -6
  59. letta/server/server.py +25 -13
  60. letta/services/agent_manager.py +186 -194
  61. letta/services/block_manager.py +1 -1
  62. letta/services/context_window_calculator/context_window_calculator.py +1 -1
  63. letta/services/context_window_calculator/token_counter.py +3 -2
  64. letta/services/file_processor/chunker/line_chunker.py +34 -0
  65. letta/services/file_processor/file_processor.py +43 -12
  66. letta/services/file_processor/parser/mistral_parser.py +11 -1
  67. letta/services/files_agents_manager.py +96 -7
  68. letta/services/group_manager.py +6 -6
  69. letta/services/helpers/agent_manager_helper.py +404 -3
  70. letta/services/identity_manager.py +1 -1
  71. letta/services/job_manager.py +1 -1
  72. letta/services/llm_batch_manager.py +1 -1
  73. letta/services/mcp/stdio_client.py +5 -1
  74. letta/services/mcp_manager.py +4 -4
  75. letta/services/message_manager.py +1 -1
  76. letta/services/organization_manager.py +1 -1
  77. letta/services/passage_manager.py +604 -19
  78. letta/services/per_agent_lock_manager.py +1 -1
  79. letta/services/provider_manager.py +1 -1
  80. letta/services/sandbox_config_manager.py +1 -1
  81. letta/services/source_manager.py +178 -19
  82. letta/services/step_manager.py +2 -2
  83. letta/services/summarizer/summarizer.py +1 -1
  84. letta/services/telemetry_manager.py +1 -1
  85. letta/services/tool_executor/builtin_tool_executor.py +117 -0
  86. letta/services/tool_executor/composio_tool_executor.py +53 -0
  87. letta/services/tool_executor/core_tool_executor.py +474 -0
  88. letta/services/tool_executor/files_tool_executor.py +138 -0
  89. letta/services/tool_executor/mcp_tool_executor.py +45 -0
  90. letta/services/tool_executor/multi_agent_tool_executor.py +123 -0
  91. letta/services/tool_executor/tool_execution_manager.py +34 -14
  92. letta/services/tool_executor/tool_execution_sandbox.py +1 -1
  93. letta/services/tool_executor/tool_executor.py +3 -802
  94. letta/services/tool_executor/tool_executor_base.py +43 -0
  95. letta/services/tool_manager.py +55 -59
  96. letta/services/tool_sandbox/e2b_sandbox.py +1 -1
  97. letta/services/tool_sandbox/local_sandbox.py +6 -3
  98. letta/services/user_manager.py +6 -3
  99. letta/settings.py +23 -2
  100. letta/utils.py +7 -2
  101. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/METADATA +4 -2
  102. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/RECORD +105 -83
  103. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/LICENSE +0 -0
  104. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/WHEEL +0 -0
  105. {letta_nightly-0.8.0.dev20250606195656.dist-info → letta_nightly-0.8.3.dev20250607000559.dist-info}/entry_points.txt +0 -0
letta/llm_api/helpers.py CHANGED
@@ -63,11 +63,11 @@ def _convert_to_structured_output_helper(property: dict) -> dict:
63
63
 
64
64
 
65
65
  def convert_to_structured_output(openai_function: dict, allow_optional: bool = False) -> dict:
66
- """Convert function call objects to structured output objects
66
+ """Convert function call objects to structured output objects.
67
67
 
68
68
  See: https://platform.openai.com/docs/guides/structured-outputs/supported-schemas
69
69
  """
70
- description = openai_function["description"] if "description" in openai_function else ""
70
+ description = openai_function.get("description", "")
71
71
 
72
72
  structured_output = {
73
73
  "name": openai_function["name"],
@@ -81,54 +81,58 @@ def convert_to_structured_output(openai_function: dict, allow_optional: bool = F
81
81
  },
82
82
  }
83
83
 
84
- # This code needs to be able to handle nested properties
85
- # For example, the param details may have "type" + "description",
86
- # but if "type" is "object" we expected "properties", where each property has details
87
- # and if "type" is "array" we expect "items": <type>
88
84
  for param, details in openai_function["parameters"]["properties"].items():
89
85
  param_type = details["type"]
90
- description = details.get("description", "")
86
+ param_description = details.get("description", "")
91
87
 
92
88
  if param_type == "object":
93
89
  if "properties" not in details:
94
- # Structured outputs requires the properties on dicts be specified ahead of time
95
- raise ValueError(f"Property {param} of type object is missing properties")
90
+ raise ValueError(f"Property {param} of type object is missing 'properties'")
96
91
  structured_output["parameters"]["properties"][param] = {
97
92
  "type": "object",
98
- "description": description,
93
+ "description": param_description,
99
94
  "properties": {k: _convert_to_structured_output_helper(v) for k, v in details["properties"].items()},
100
95
  "additionalProperties": False,
101
96
  "required": list(details["properties"].keys()),
102
97
  }
103
98
 
104
99
  elif param_type == "array":
105
- structured_output["parameters"]["properties"][param] = {
106
- "type": "array",
107
- "description": description,
108
- "items": _convert_to_structured_output_helper(details["items"]),
109
- }
100
+ items_schema = details.get("items")
101
+ prefix_items_schema = details.get("prefixItems")
102
+
103
+ if prefix_items_schema:
104
+ # assume fixed-length tuple — safe fallback to use first type for items
105
+ fallback_item = prefix_items_schema[0] if isinstance(prefix_items_schema, list) else prefix_items_schema
106
+ structured_output["parameters"]["properties"][param] = {
107
+ "type": "array",
108
+ "description": param_description,
109
+ "prefixItems": [_convert_to_structured_output_helper(item) for item in prefix_items_schema],
110
+ "items": _convert_to_structured_output_helper(fallback_item),
111
+ "minItems": details.get("minItems", len(prefix_items_schema)),
112
+ "maxItems": details.get("maxItems", len(prefix_items_schema)),
113
+ }
114
+ elif items_schema:
115
+ structured_output["parameters"]["properties"][param] = {
116
+ "type": "array",
117
+ "description": param_description,
118
+ "items": _convert_to_structured_output_helper(items_schema),
119
+ }
120
+ else:
121
+ raise ValueError(f"Array param '{param}' is missing both 'items' and 'prefixItems'")
110
122
 
111
123
  else:
112
- structured_output["parameters"]["properties"][param] = {
113
- "type": param_type, # simple type
114
- "description": description,
124
+ prop = {
125
+ "type": param_type,
126
+ "description": param_description,
115
127
  }
116
-
117
- if "enum" in details:
118
- structured_output["parameters"]["properties"][param]["enum"] = details["enum"]
128
+ if "enum" in details:
129
+ prop["enum"] = details["enum"]
130
+ structured_output["parameters"]["properties"][param] = prop
119
131
 
120
132
  if not allow_optional:
121
- # Add all properties to required list
122
133
  structured_output["parameters"]["required"] = list(structured_output["parameters"]["properties"].keys())
123
-
124
134
  else:
125
- # See what parameters exist that aren't required
126
- # Those are implied "optional" types
127
- # For those types, turn each of them into a union type with "null"
128
- # e.g.
129
- # "type": "string" -> "type": ["string", "null"]
130
- # TODO
131
- raise NotImplementedError
135
+ raise NotImplementedError("Optional parameter handling is not implemented.")
132
136
 
133
137
  return structured_output
134
138
 
@@ -292,6 +296,8 @@ def unpack_inner_thoughts_from_kwargs(choice: Choice, inner_thoughts_key: str) -
292
296
 
293
297
  except json.JSONDecodeError as e:
294
298
  warnings.warn(f"Failed to strip inner thoughts from kwargs: {e}")
299
+ print(f"\nFailed to strip inner thoughts from kwargs: {e}")
300
+ print(f"\nTool call arguments: {tool_call.function.arguments}")
295
301
  raise e
296
302
  else:
297
303
  warnings.warn(f"Did not find tool call in message: {str(message)}")
@@ -26,6 +26,7 @@ from letta.local_llm.chat_completion_proxy import get_chat_completion
26
26
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION
27
27
  from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages
28
28
  from letta.orm.user import User
29
+ from letta.otel.tracing import log_event, trace_method
29
30
  from letta.schemas.enums import ProviderCategory
30
31
  from letta.schemas.llm_config import LLMConfig
31
32
  from letta.schemas.message import Message
@@ -35,7 +36,6 @@ from letta.schemas.provider_trace import ProviderTraceCreate
35
36
  from letta.services.telemetry_manager import TelemetryManager
36
37
  from letta.settings import ModelSettings
37
38
  from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface
38
- from letta.tracing import log_event, trace_method
39
39
 
40
40
  LLM_API_PROVIDER_OPTIONS = ["openai", "azure", "anthropic", "google_ai", "cohere", "local", "groq", "deepseek"]
41
41
 
@@ -1,3 +1,4 @@
1
+ import json
1
2
  from abc import abstractmethod
2
3
  from typing import TYPE_CHECKING, Dict, List, Optional, Union
3
4
 
@@ -6,13 +7,13 @@ from openai import AsyncStream, Stream
6
7
  from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
7
8
 
8
9
  from letta.errors import LLMError
10
+ from letta.otel.tracing import log_event, trace_method
9
11
  from letta.schemas.embedding_config import EmbeddingConfig
10
12
  from letta.schemas.llm_config import LLMConfig
11
13
  from letta.schemas.message import Message
12
14
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
13
15
  from letta.schemas.provider_trace import ProviderTraceCreate
14
16
  from letta.services.telemetry_manager import TelemetryManager
15
- from letta.tracing import log_event, trace_method
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from letta.orm import User
@@ -186,3 +187,30 @@ class LLMClientBase:
186
187
  An LLMError subclass that represents the error in a provider-agnostic way
187
188
  """
188
189
  return LLMError(f"Unhandled LLM error: {str(e)}")
190
+
191
+ def _fix_truncated_json_response(self, response: ChatCompletionResponse) -> ChatCompletionResponse:
192
+ """
193
+ Fixes truncated JSON responses by ensuring the content is properly formatted.
194
+ This is a workaround for some providers that may return incomplete JSON.
195
+ """
196
+ if response.choices and response.choices[0].message and response.choices[0].message.tool_calls:
197
+ tool_call_args_str = response.choices[0].message.tool_calls[0].function.arguments
198
+ try:
199
+ json.loads(tool_call_args_str)
200
+ except json.JSONDecodeError:
201
+ try:
202
+ json_str_end = ""
203
+ quote_count = tool_call_args_str.count('"')
204
+ if quote_count % 2 != 0:
205
+ json_str_end = json_str_end + '"'
206
+
207
+ open_braces = tool_call_args_str.count("{")
208
+ close_braces = tool_call_args_str.count("}")
209
+ missing_braces = open_braces - close_braces
210
+ json_str_end += "}" * missing_braces
211
+ fixed_tool_call_args_str = tool_call_args_str[: -len(json_str_end)] + json_str_end
212
+ json.loads(fixed_tool_call_args_str)
213
+ response.choices[0].message.tool_calls[0].function.arguments = fixed_tool_call_args_str
214
+ except json.JSONDecodeError:
215
+ pass
216
+ return response
letta/llm_api/openai.py CHANGED
@@ -19,6 +19,7 @@ from letta.llm_api.openai_client import (
19
19
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
20
20
  from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages
21
21
  from letta.log import get_logger
22
+ from letta.otel.tracing import log_event
22
23
  from letta.schemas.llm_config import LLMConfig
23
24
  from letta.schemas.message import Message as _Message
24
25
  from letta.schemas.message import MessageRole as _MessageRole
@@ -36,7 +37,6 @@ from letta.schemas.openai.chat_completion_response import (
36
37
  )
37
38
  from letta.schemas.openai.embedding_response import EmbeddingResponse
38
39
  from letta.streaming_interface import AgentChunkStreamingInterface, AgentRefreshStreamingInterface
39
- from letta.tracing import log_event
40
40
  from letta.utils import get_tool_call_id, smart_urljoin
41
41
 
42
42
  logger = get_logger(__name__)
@@ -8,11 +8,11 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
8
8
 
9
9
  from letta.constants import LETTA_MODEL_ENDPOINT
10
10
  from letta.errors import (
11
+ ContextWindowExceededError,
11
12
  ErrorCode,
12
13
  LLMAuthenticationError,
13
14
  LLMBadRequestError,
14
15
  LLMConnectionError,
15
- LLMContextWindowExceededError,
16
16
  LLMNotFoundError,
17
17
  LLMPermissionDeniedError,
18
18
  LLMRateLimitError,
@@ -23,6 +23,7 @@ from letta.llm_api.helpers import add_inner_thoughts_to_functions, convert_to_st
23
23
  from letta.llm_api.llm_client_base import LLMClientBase
24
24
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG, INNER_THOUGHTS_KWARG_DESCRIPTION, INNER_THOUGHTS_KWARG_DESCRIPTION_GO_FIRST
25
25
  from letta.log import get_logger
26
+ from letta.otel.tracing import trace_method
26
27
  from letta.schemas.embedding_config import EmbeddingConfig
27
28
  from letta.schemas.enums import ProviderCategory, ProviderType
28
29
  from letta.schemas.llm_config import LLMConfig
@@ -34,7 +35,6 @@ from letta.schemas.openai.chat_completion_request import Tool as OpenAITool
34
35
  from letta.schemas.openai.chat_completion_request import ToolFunctionChoice, cast_message_to_subtype
35
36
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
36
37
  from letta.settings import model_settings
37
- from letta.tracing import trace_method
38
38
 
39
39
  logger = get_logger(__name__)
40
40
 
@@ -280,7 +280,7 @@ class OpenAIClient(LLMClientBase):
280
280
  # OpenAI's response structure directly maps to ChatCompletionResponse
281
281
  # We just need to instantiate the Pydantic model for validation and type safety.
282
282
  chat_completion_response = ChatCompletionResponse(**response_data)
283
-
283
+ chat_completion_response = self._fix_truncated_json_response(chat_completion_response)
284
284
  # Unpack inner thoughts if they were embedded in function arguments
285
285
  if llm_config.put_inner_thoughts_in_kwargs:
286
286
  chat_completion_response = unpack_all_inner_thoughts_from_kwargs(
@@ -342,11 +342,9 @@ class OpenAIClient(LLMClientBase):
342
342
  # Check message content if finer-grained errors are needed
343
343
  # Example: if "context_length_exceeded" in str(e): return LLMContextLengthExceededError(...)
344
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,
345
+ if "This model's maximum context length is" in str(e):
346
+ return ContextWindowExceededError(
347
+ message=f"Bad request to OpenAI (context window exceeded): {str(e)}",
350
348
  )
351
349
  else:
352
350
  return LLMBadRequestError(
@@ -20,9 +20,9 @@ from letta.local_llm.utils import count_tokens, get_available_wrappers
20
20
  from letta.local_llm.vllm.api import get_vllm_completion
21
21
  from letta.local_llm.webui.api import get_webui_completion
22
22
  from letta.local_llm.webui.legacy_api import get_webui_completion as get_webui_completion_legacy
23
+ from letta.otel.tracing import log_event
23
24
  from letta.prompts.gpt_summarize import SYSTEM as SUMMARIZE_SYSTEM_MESSAGE
24
25
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse, Choice, Message, ToolCall, UsageStatistics
25
- from letta.tracing import log_event
26
26
  from letta.utils import get_tool_call_id
27
27
 
28
28
  has_shown_warning = False
letta/memory.py CHANGED
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List
3
3
  from letta.constants import MESSAGE_SUMMARY_REQUEST_ACK
4
4
  from letta.llm_api.llm_api_tools import create
5
5
  from letta.llm_api.llm_client import LLMClient
6
+ from letta.otel.tracing import trace_method
6
7
  from letta.prompts.gpt_summarize import SYSTEM as SUMMARY_PROMPT_SYSTEM
7
8
  from letta.schemas.agent import AgentState
8
9
  from letta.schemas.enums import MessageRole
@@ -10,7 +11,6 @@ from letta.schemas.letta_message_content import TextContent
10
11
  from letta.schemas.memory import Memory
11
12
  from letta.schemas.message import Message
12
13
  from letta.settings import summarizer_settings
13
- from letta.tracing import trace_method
14
14
  from letta.utils import count_tokens, printd
15
15
 
16
16
  if TYPE_CHECKING:
letta/orm/enums.py CHANGED
@@ -9,6 +9,7 @@ class ToolType(str, Enum):
9
9
  LETTA_SLEEPTIME_CORE = "letta_sleeptime_core"
10
10
  LETTA_VOICE_SLEEPTIME_CORE = "letta_voice_sleeptime_core"
11
11
  LETTA_BUILTIN = "letta_builtin"
12
+ LETTA_FILES_CORE = "letta_files_core"
12
13
  EXTERNAL_COMPOSIO = "external_composio"
13
14
  EXTERNAL_LANGCHAIN = "external_langchain"
14
15
  # TODO is "external" the right name here? Since as of now, MCP is local / doesn't support remote?
letta/orm/file.py CHANGED
@@ -1,10 +1,13 @@
1
+ import uuid
1
2
  from typing import TYPE_CHECKING, List, Optional
2
3
 
3
- from sqlalchemy import Integer, String
4
+ from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, desc
5
+ from sqlalchemy.ext.asyncio import AsyncAttrs
4
6
  from sqlalchemy.orm import Mapped, mapped_column, relationship
5
7
 
6
8
  from letta.orm.mixins import OrganizationMixin, SourceMixin
7
9
  from letta.orm.sqlalchemy_base import SqlalchemyBase
10
+ from letta.schemas.enums import FileProcessingStatus
8
11
  from letta.schemas.file import FileMetadata as PydanticFileMetadata
9
12
 
10
13
  if TYPE_CHECKING:
@@ -14,11 +17,36 @@ if TYPE_CHECKING:
14
17
  from letta.orm.source import Source
15
18
 
16
19
 
17
- class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin):
20
+ # TODO: Note that this is NOT organization scoped, this is potentially dangerous if we misuse this
21
+ # TODO: This should ONLY be manipulated internally in relation to FileMetadata.content
22
+ # TODO: Leaving organization_id out of this for now for simplicity
23
+ class FileContent(SqlalchemyBase):
24
+ """Holds the full text content of a file (potentially large)."""
25
+
26
+ __tablename__ = "file_contents"
27
+ __table_args__ = (UniqueConstraint("file_id", name="uq_file_contents_file_id"),)
28
+
29
+ # TODO: We want to migrate all the ORM models to do this, so we will need to move this to the SqlalchemyBase
30
+ # TODO: Some still rely on the Pydantic object to do this
31
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"file_content-{uuid.uuid4()}")
32
+ file_id: Mapped[str] = mapped_column(ForeignKey("files.id", ondelete="CASCADE"), nullable=False, doc="Foreign key to files table.")
33
+
34
+ text: Mapped[str] = mapped_column(Text, nullable=False, doc="Full plain-text content of the file (e.g., extracted from a PDF).")
35
+
36
+ # back-reference to FileMetadata
37
+ file: Mapped["FileMetadata"] = relationship(back_populates="content", lazy="selectin")
38
+
39
+
40
+ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin, AsyncAttrs):
18
41
  """Represents an uploaded file."""
19
42
 
20
43
  __tablename__ = "files"
21
44
  __pydantic_model__ = PydanticFileMetadata
45
+ __table_args__ = (
46
+ Index("ix_files_org_created", "organization_id", desc("created_at")),
47
+ Index("ix_files_source_created", "source_id", desc("created_at")),
48
+ Index("ix_files_processing_status", "processing_status"),
49
+ )
22
50
 
23
51
  file_name: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The name of the file.")
24
52
  file_path: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The file path on the system.")
@@ -26,6 +54,11 @@ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin):
26
54
  file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, doc="The size of the file in bytes.")
27
55
  file_creation_date: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The creation date of the file.")
28
56
  file_last_modified_date: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="The last modified date of the file.")
57
+ processing_status: Mapped[FileProcessingStatus] = mapped_column(
58
+ String, default=FileProcessingStatus.PENDING, nullable=False, doc="The current processing status of the file."
59
+ )
60
+
61
+ error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True, doc="Any error message encountered during processing.")
29
62
 
30
63
  # relationships
31
64
  organization: Mapped["Organization"] = relationship("Organization", back_populates="files", lazy="selectin")
@@ -33,4 +66,48 @@ class FileMetadata(SqlalchemyBase, OrganizationMixin, SourceMixin):
33
66
  source_passages: Mapped[List["SourcePassage"]] = relationship(
34
67
  "SourcePassage", back_populates="file", lazy="selectin", cascade="all, delete-orphan"
35
68
  )
36
- file_agents: Mapped[List["FileAgent"]] = relationship("FileAgent", back_populates="file", lazy="selectin")
69
+ file_agents: Mapped[List["FileAgent"]] = relationship(
70
+ "FileAgent",
71
+ back_populates="file",
72
+ lazy="selectin",
73
+ cascade="all, delete-orphan",
74
+ passive_deletes=True, # ← add this
75
+ )
76
+ content: Mapped[Optional["FileContent"]] = relationship(
77
+ "FileContent",
78
+ uselist=False,
79
+ back_populates="file",
80
+ lazy="raise", # raises if you access without eager load
81
+ cascade="all, delete-orphan",
82
+ )
83
+
84
+ async def to_pydantic_async(self, include_content: bool = False) -> PydanticFileMetadata:
85
+ """
86
+ Async version of `to_pydantic` that supports optional relationship loading
87
+ without requiring `expire_on_commit=False`.
88
+ """
89
+
90
+ # Load content relationship if requested
91
+ if include_content:
92
+ content_obj = await self.awaitable_attrs.content
93
+ content_text = content_obj.text if content_obj else None
94
+ else:
95
+ content_text = None
96
+
97
+ return PydanticFileMetadata(
98
+ id=self.id,
99
+ organization_id=self.organization_id,
100
+ source_id=self.source_id,
101
+ file_name=self.file_name,
102
+ file_path=self.file_path,
103
+ file_type=self.file_type,
104
+ file_size=self.file_size,
105
+ file_creation_date=self.file_creation_date,
106
+ file_last_modified_date=self.file_last_modified_date,
107
+ processing_status=self.processing_status,
108
+ error_message=self.error_message,
109
+ created_at=self.created_at,
110
+ updated_at=self.updated_at,
111
+ is_deleted=self.is_deleted,
112
+ content=content_text,
113
+ )
letta/orm/files_agents.py CHANGED
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Optional
5
5
  from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
6
6
  from sqlalchemy.orm import Mapped, mapped_column, relationship
7
7
 
8
+ from letta.constants import CORE_MEMORY_SOURCE_CHAR_LIMIT, FILE_IS_TRUNCATED_WARNING
8
9
  from letta.orm.mixins import OrganizationMixin
9
10
  from letta.orm.sqlalchemy_base import SqlalchemyBase
10
11
  from letta.schemas.block import Block as PydanticBlock
@@ -26,6 +27,8 @@ class FileAgent(SqlalchemyBase, OrganizationMixin):
26
27
  __table_args__ = (
27
28
  Index("ix_files_agents_file_id_agent_id", "file_id", "agent_id"),
28
29
  UniqueConstraint("file_id", "agent_id", name="uq_files_agents_file_agent"),
30
+ UniqueConstraint("agent_id", "file_name", name="uq_files_agents_agent_file_name"),
31
+ Index("ix_files_agents_agent_file_name", "agent_id", "file_name"),
29
32
  )
30
33
  __pydantic_model__ = PydanticFileAgent
31
34
 
@@ -33,6 +36,7 @@ class FileAgent(SqlalchemyBase, OrganizationMixin):
33
36
  # TODO: Some still rely on the Pydantic object to do this
34
37
  id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: f"file_agent-{uuid.uuid4()}")
35
38
  file_id: Mapped[str] = mapped_column(String, ForeignKey("files.id", ondelete="CASCADE"), primary_key=True, doc="ID of the file.")
39
+ file_name: Mapped[str] = mapped_column(String, nullable=False, doc="Denormalized copy of files.file_name; unique per agent.")
36
40
  agent_id: Mapped[str] = mapped_column(String, ForeignKey("agents.id", ondelete="CASCADE"), primary_key=True, doc="ID of the agent.")
37
41
 
38
42
  is_open: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, doc="True if the agent currently has the file open.")
@@ -55,11 +59,20 @@ class FileAgent(SqlalchemyBase, OrganizationMixin):
55
59
  "FileMetadata",
56
60
  foreign_keys=[file_id],
57
61
  lazy="selectin",
62
+ back_populates="file_agents",
63
+ passive_deletes=True, # ← add this
58
64
  )
59
65
 
60
66
  # TODO: This is temporary as we figure out if we want FileBlock as a first class citizen
61
67
  def to_pydantic_block(self) -> PydanticBlock:
62
68
  visible_content = self.visible_content if self.visible_content and self.is_open else ""
69
+
70
+ # Truncate content and add warnings here when converting from FileAgent to Block
71
+ if len(visible_content) > CORE_MEMORY_SOURCE_CHAR_LIMIT:
72
+ truncated_warning = f"...[TRUNCATED]\n{FILE_IS_TRUNCATED_WARNING}"
73
+ visible_content = visible_content[: CORE_MEMORY_SOURCE_CHAR_LIMIT - len(truncated_warning)]
74
+ visible_content += truncated_warning
75
+
63
76
  return PydanticBlock(
64
77
  organization_id=self.organization_id,
65
78
  value=visible_content,
letta/orm/passage.py CHANGED
@@ -47,6 +47,8 @@ class SourcePassage(BasePassage, FileMixin, SourceMixin):
47
47
 
48
48
  __tablename__ = "source_passages"
49
49
 
50
+ file_name: Mapped[str] = mapped_column(doc="The name of the file that this passage was derived from")
51
+
50
52
  @declared_attr
51
53
  def file(cls) -> Mapped["FileMetadata"]:
52
54
  """Relationship to file"""
@@ -1,13 +1,15 @@
1
+ import inspect
1
2
  from datetime import datetime
2
3
  from enum import Enum
3
4
  from functools import wraps
4
5
  from pprint import pformat
5
6
  from typing import TYPE_CHECKING, List, Literal, Optional, Tuple, Union
6
7
 
7
- from sqlalchemy import String, and_, delete, func, or_, select, text
8
+ from sqlalchemy import Sequence, String, and_, delete, func, or_, select, text
8
9
  from sqlalchemy.exc import DBAPIError, IntegrityError, TimeoutError
9
10
  from sqlalchemy.ext.asyncio import AsyncSession
10
11
  from sqlalchemy.orm import Mapped, Session, mapped_column
12
+ from sqlalchemy.orm.interfaces import ORMOption
11
13
 
12
14
  from letta.log import get_logger
13
15
  from letta.orm.base import Base, CommonSqlalchemyMetaMixins
@@ -23,16 +25,28 @@ logger = get_logger(__name__)
23
25
 
24
26
  def handle_db_timeout(func):
25
27
  """Decorator to handle SQLAlchemy TimeoutError and wrap it in a custom exception."""
28
+ if not inspect.iscoroutinefunction(func):
26
29
 
27
- @wraps(func)
28
- def wrapper(*args, **kwargs):
29
- try:
30
- return func(*args, **kwargs)
31
- except TimeoutError as e:
32
- logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}")
33
- raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e)
30
+ @wraps(func)
31
+ def wrapper(*args, **kwargs):
32
+ try:
33
+ return func(*args, **kwargs)
34
+ except TimeoutError as e:
35
+ logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}")
36
+ raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e)
37
+
38
+ return wrapper
39
+ else:
40
+
41
+ @wraps(func)
42
+ async def async_wrapper(*args, **kwargs):
43
+ try:
44
+ return await func(*args, **kwargs)
45
+ except TimeoutError as e:
46
+ logger.error(f"Timeout while executing {func.__name__} with args {args} and kwargs {kwargs}: {e}")
47
+ raise DatabaseTimeoutError(message=f"Timeout occurred in {func.__name__}.", original_exception=e)
34
48
 
35
- return wrapper
49
+ return async_wrapper
36
50
 
37
51
 
38
52
  class AccessType(str, Enum):
@@ -163,6 +177,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
163
177
  join_conditions: Optional[Union[Tuple, List]] = None,
164
178
  identifier_keys: Optional[List[str]] = None,
165
179
  identity_id: Optional[str] = None,
180
+ query_options: Sequence[ORMOption] | None = None, # ← new
166
181
  **kwargs,
167
182
  ) -> List["SqlalchemyBase"]:
168
183
  """
@@ -224,6 +239,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
224
239
  identity_id=identity_id,
225
240
  **kwargs,
226
241
  )
242
+ if query_options:
243
+ for opt in query_options:
244
+ query = query.options(opt)
227
245
 
228
246
  # Execute the query
229
247
  results = await db_session.execute(query)
@@ -472,14 +490,19 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
472
490
  Raises:
473
491
  NoResultFound: if the object is not found
474
492
  """
493
+ from letta.settings import settings
494
+
475
495
  identifiers = [] if identifier is None else [identifier]
476
496
  query, query_conditions = cls._read_multiple_preprocess(identifiers, actor, access, access_type, check_is_deleted, **kwargs)
477
- await db_session.execute(text("SET LOCAL enable_seqscan = OFF"))
497
+
498
+ if settings.letta_pg_uri_no_default:
499
+ await db_session.execute(text("SET LOCAL enable_seqscan = OFF"))
478
500
  try:
479
501
  result = await db_session.execute(query)
480
502
  item = result.scalar_one_or_none()
481
503
  finally:
482
- await db_session.execute(text("SET LOCAL enable_seqscan = ON"))
504
+ if settings.letta_pg_uri_no_default:
505
+ await db_session.execute(text("SET LOCAL enable_seqscan = ON"))
483
506
 
484
507
  if item is None:
485
508
  raise NoResultFound(f"{cls.__name__} not found with {', '.join(query_conditions if query_conditions else ['no conditions'])}")
letta/otel/__init__.py ADDED
File without changes
letta/otel/context.py ADDED
@@ -0,0 +1,25 @@
1
+ from contextvars import ContextVar
2
+ from typing import Any, Dict
3
+
4
+ # Create context var at module level (outside middleware)
5
+ request_attributes: ContextVar[Dict[str, Any]] = ContextVar("request_attributes", default={})
6
+
7
+
8
+ # Helper functions
9
+ def set_ctx_attributes(attrs: Dict[str, Any]):
10
+ """Set attributes in current context"""
11
+ current = request_attributes.get()
12
+ new_attrs = {**current, **attrs}
13
+ request_attributes.set(new_attrs)
14
+
15
+
16
+ def add_ctx_attribute(key: str, value: Any):
17
+ """Add single attribute to current context"""
18
+ current = request_attributes.get()
19
+ new_attrs = {**current, key: value}
20
+ request_attributes.set(new_attrs)
21
+
22
+
23
+ def get_ctx_attributes() -> Dict[str, Any]:
24
+ """Get all attributes from current context"""
25
+ return request_attributes.get()
letta/otel/events.py ADDED
File without changes