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
@@ -5,12 +5,13 @@ from fastapi import UploadFile
5
5
 
6
6
  from letta.log import get_logger
7
7
  from letta.schemas.agent import AgentState
8
- from letta.schemas.enums import JobStatus
8
+ from letta.schemas.enums import FileProcessingStatus, JobStatus
9
9
  from letta.schemas.file import FileMetadata
10
10
  from letta.schemas.job import Job, JobUpdate
11
11
  from letta.schemas.passage import Passage
12
12
  from letta.schemas.user import User
13
13
  from letta.server.server import SyncServer
14
+ from letta.services.file_processor.chunker.line_chunker import LineChunker
14
15
  from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker
15
16
  from letta.services.file_processor.embedder.openai_embedder import OpenAIEmbedder
16
17
  from letta.services.file_processor.parser.mistral_parser import MistralFileParser
@@ -34,6 +35,7 @@ class FileProcessor:
34
35
  ):
35
36
  self.file_parser = file_parser
36
37
  self.text_chunker = text_chunker
38
+ self.line_chunker = LineChunker()
37
39
  self.embedder = embedder
38
40
  self.max_file_size = max_file_size
39
41
  self.source_manager = SourceManager()
@@ -52,9 +54,12 @@ class FileProcessor:
52
54
  job: Optional[Job] = None,
53
55
  ) -> List[Passage]:
54
56
  file_metadata = self._extract_upload_file_metadata(file, source_id=source_id)
55
- file_metadata = await self.source_manager.create_file(file_metadata, self.actor)
56
57
  filename = file_metadata.file_name
57
58
 
59
+ # Create file as early as possible with no content
60
+ file_metadata.processing_status = FileProcessingStatus.PARSING # Parsing now
61
+ file_metadata = await self.source_manager.create_file(file_metadata, self.actor)
62
+
58
63
  try:
59
64
  # Ensure we're working with bytes
60
65
  if isinstance(content, str):
@@ -66,11 +71,35 @@ class FileProcessor:
66
71
  logger.info(f"Starting OCR extraction for {filename}")
67
72
  ocr_response = await self.file_parser.extract_text(content, mime_type=file_metadata.file_type)
68
73
 
74
+ # update file with raw text
75
+ raw_markdown_text = "".join([page.markdown for page in ocr_response.pages])
76
+ file_metadata = await self.source_manager.upsert_file_content(
77
+ file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor
78
+ )
79
+ file_metadata = await self.source_manager.update_file_status(
80
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
81
+ )
82
+
83
+ # Insert to agent context window
84
+ # TODO: Rethink this line chunking mechanism
85
+ content_lines = self.line_chunker.chunk_text(text=raw_markdown_text)
86
+ visible_content = "\n".join(content_lines)
87
+
88
+ await server.insert_file_into_context_windows(
89
+ source_id=source_id,
90
+ text=visible_content,
91
+ file_id=file_metadata.id,
92
+ file_name=file_metadata.file_name,
93
+ actor=self.actor,
94
+ agent_states=agent_states,
95
+ )
96
+
69
97
  if not ocr_response or len(ocr_response.pages) == 0:
70
98
  raise ValueError("No text extracted from PDF")
71
99
 
72
100
  logger.info("Chunking extracted text")
73
101
  all_passages = []
102
+
74
103
  for page in ocr_response.pages:
75
104
  chunks = self.text_chunker.chunk_text(page)
76
105
 
@@ -82,28 +111,26 @@ class FileProcessor:
82
111
  )
83
112
  all_passages.extend(passages)
84
113
 
85
- all_passages = await self.passage_manager.create_many_passages_async(all_passages, self.actor)
114
+ all_passages = await self.passage_manager.create_many_source_passages_async(
115
+ passages=all_passages, file_metadata=file_metadata, actor=self.actor
116
+ )
86
117
 
87
118
  logger.info(f"Successfully processed {filename}: {len(all_passages)} passages")
88
119
 
89
- await server.insert_file_into_context_windows(
90
- source_id=source_id,
91
- text="".join([ocr_response.pages[i].markdown for i in range(min(3, len(ocr_response.pages)))]),
92
- file_id=file_metadata.id,
93
- actor=self.actor,
94
- agent_states=agent_states,
95
- )
96
-
97
120
  # update job status
98
121
  if job:
99
122
  job.status = JobStatus.completed
100
123
  job.metadata["num_passages"] = len(all_passages)
101
124
  await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor)
102
125
 
126
+ await self.source_manager.update_file_status(
127
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
128
+ )
129
+
103
130
  return all_passages
104
131
 
105
132
  except Exception as e:
106
- logger.error(f"PDF processing failed for {filename}: {str(e)}")
133
+ logger.error(f"File processing failed for {filename}: {str(e)}")
107
134
 
108
135
  # update job status
109
136
  if job:
@@ -111,6 +138,10 @@ class FileProcessor:
111
138
  job.metadata["error"] = str(e)
112
139
  await self.job_manager.update_job_by_id_async(job_id=job.id, job_update=JobUpdate(**job.model_dump()), actor=self.actor)
113
140
 
141
+ await self.source_manager.update_file_status(
142
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e)
143
+ )
144
+
114
145
  return []
115
146
 
116
147
  def _extract_upload_file_metadata(self, file: UploadFile, source_id: str) -> FileMetadata:
@@ -9,6 +9,16 @@ from letta.settings import settings
9
9
  logger = get_logger(__name__)
10
10
 
11
11
 
12
+ SIMPLE_TEXT_MIME_TYPES = {
13
+ "text/plain",
14
+ "text/markdown",
15
+ "text/x-markdown",
16
+ "application/json",
17
+ "application/jsonl",
18
+ "application/x-jsonlines",
19
+ }
20
+
21
+
12
22
  class MistralFileParser(FileParser):
13
23
  """Mistral-based OCR extraction"""
14
24
 
@@ -23,7 +33,7 @@ class MistralFileParser(FileParser):
23
33
 
24
34
  # TODO: Kind of hacky...we try to exit early here?
25
35
  # TODO: Create our internal file parser representation we return instead of OCRResponse
26
- if mime_type == "text/plain":
36
+ if mime_type in SIMPLE_TEXT_MIME_TYPES or mime_type.startswith("text/"):
27
37
  text = content.decode("utf-8", errors="replace")
28
38
  return OCRResponse(
29
39
  model=self.model,
@@ -5,10 +5,11 @@ from sqlalchemy import and_, func, select, update
5
5
 
6
6
  from letta.orm.errors import NoResultFound
7
7
  from letta.orm.files_agents import FileAgent as FileAgentModel
8
+ from letta.otel.tracing import trace_method
9
+ from letta.schemas.block import Block as PydanticBlock
8
10
  from letta.schemas.file import FileAgent as PydanticFileAgent
9
11
  from letta.schemas.user import User as PydanticUser
10
12
  from letta.server.db import db_registry
11
- from letta.tracing import trace_method
12
13
  from letta.utils import enforce_types
13
14
 
14
15
 
@@ -22,6 +23,7 @@ class FileAgentManager:
22
23
  *,
23
24
  agent_id: str,
24
25
  file_id: str,
26
+ file_name: str,
25
27
  actor: PydanticUser,
26
28
  is_open: bool = True,
27
29
  visible_content: Optional[str] = None,
@@ -38,6 +40,7 @@ class FileAgentManager:
38
40
  and_(
39
41
  FileAgentModel.agent_id == agent_id,
40
42
  FileAgentModel.file_id == file_id,
43
+ FileAgentModel.file_name == file_name,
41
44
  FileAgentModel.organization_id == actor.organization_id,
42
45
  )
43
46
  )
@@ -61,6 +64,7 @@ class FileAgentManager:
61
64
  assoc = FileAgentModel(
62
65
  agent_id=agent_id,
63
66
  file_id=file_id,
67
+ file_name=file_name,
64
68
  organization_id=actor.organization_id,
65
69
  is_open=is_open,
66
70
  visible_content=visible_content,
@@ -71,7 +75,7 @@ class FileAgentManager:
71
75
 
72
76
  @enforce_types
73
77
  @trace_method
74
- async def update_file_agent(
78
+ async def update_file_agent_by_id(
75
79
  self,
76
80
  *,
77
81
  agent_id: str,
@@ -82,7 +86,33 @@ class FileAgentManager:
82
86
  ) -> PydanticFileAgent:
83
87
  """Patch an existing association row."""
84
88
  async with db_registry.async_session() as session:
85
- assoc = await self._get_association(session, agent_id, file_id, actor)
89
+ assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor)
90
+
91
+ if is_open is not None:
92
+ assoc.is_open = is_open
93
+ if visible_content is not None:
94
+ assoc.visible_content = visible_content
95
+
96
+ # touch timestamp
97
+ assoc.last_accessed_at = datetime.now(timezone.utc)
98
+
99
+ await assoc.update_async(session, actor=actor)
100
+ return assoc.to_pydantic()
101
+
102
+ @enforce_types
103
+ @trace_method
104
+ async def update_file_agent_by_name(
105
+ self,
106
+ *,
107
+ agent_id: str,
108
+ file_name: str,
109
+ actor: PydanticUser,
110
+ is_open: Optional[bool] = None,
111
+ visible_content: Optional[str] = None,
112
+ ) -> PydanticFileAgent:
113
+ """Patch an existing association row."""
114
+ async with db_registry.async_session() as session:
115
+ assoc = await self._get_association_by_file_name(session, agent_id, file_name, actor)
86
116
 
87
117
  if is_open is not None:
88
118
  assoc.is_open = is_open
@@ -100,15 +130,61 @@ class FileAgentManager:
100
130
  async def detach_file(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> None:
101
131
  """Hard-delete the association."""
102
132
  async with db_registry.async_session() as session:
103
- assoc = await self._get_association(session, agent_id, file_id, actor)
133
+ assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor)
104
134
  await assoc.hard_delete_async(session, actor=actor)
105
135
 
106
136
  @enforce_types
107
137
  @trace_method
108
- async def get_file_agent(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]:
138
+ async def get_file_agent_by_id(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]:
139
+ async with db_registry.async_session() as session:
140
+ try:
141
+ assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor)
142
+ return assoc.to_pydantic()
143
+ except NoResultFound:
144
+ return None
145
+
146
+ @enforce_types
147
+ @trace_method
148
+ async def get_all_file_blocks_by_name(
149
+ self,
150
+ *,
151
+ file_names: List[str],
152
+ actor: PydanticUser,
153
+ ) -> List[PydanticBlock]:
154
+ """
155
+ Retrieve multiple FileAgent associations by their IDs in a single query.
156
+
157
+ Args:
158
+ file_names: List of file names to retrieve
159
+ actor: The user making the request
160
+
161
+ Returns:
162
+ List of PydanticFileAgent objects found (may be fewer than requested if some IDs don't exist)
163
+ """
164
+ if not file_names:
165
+ return []
166
+
167
+ async with db_registry.async_session() as session:
168
+ # Use IN clause for efficient bulk retrieval
169
+ query = select(FileAgentModel).where(
170
+ and_(
171
+ FileAgentModel.file_name.in_(file_names),
172
+ FileAgentModel.organization_id == actor.organization_id,
173
+ )
174
+ )
175
+
176
+ # Execute query and get all results
177
+ rows = (await session.execute(query)).scalars().all()
178
+
179
+ # Convert to Pydantic models
180
+ return [row.to_pydantic_block() for row in rows]
181
+
182
+ @enforce_types
183
+ @trace_method
184
+ async def get_file_agent_by_file_name(self, *, agent_id: str, file_name: str, actor: PydanticUser) -> Optional[PydanticFileAgent]:
109
185
  async with db_registry.async_session() as session:
110
186
  try:
111
- assoc = await self._get_association(session, agent_id, file_id, actor)
187
+ assoc = await self._get_association_by_file_name(session, agent_id, file_name, actor)
112
188
  return assoc.to_pydantic()
113
189
  except NoResultFound:
114
190
  return None
@@ -170,7 +246,7 @@ class FileAgentManager:
170
246
  await session.execute(stmt)
171
247
  await session.commit()
172
248
 
173
- async def _get_association(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
249
+ async def _get_association_by_file_id(self, session, agent_id: str, file_id: str, actor: PydanticUser) -> FileAgentModel:
174
250
  q = select(FileAgentModel).where(
175
251
  and_(
176
252
  FileAgentModel.agent_id == agent_id,
@@ -182,3 +258,16 @@ class FileAgentManager:
182
258
  if not assoc:
183
259
  raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_id={file_id}) not found in org {actor.organization_id}")
184
260
  return assoc
261
+
262
+ async def _get_association_by_file_name(self, session, agent_id: str, file_name: str, actor: PydanticUser) -> FileAgentModel:
263
+ q = select(FileAgentModel).where(
264
+ and_(
265
+ FileAgentModel.agent_id == agent_id,
266
+ FileAgentModel.file_name == file_name,
267
+ FileAgentModel.organization_id == actor.organization_id,
268
+ )
269
+ )
270
+ assoc = await session.scalar(q)
271
+ if not assoc:
272
+ raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_name={file_name}) not found in org {actor.organization_id}")
273
+ return assoc
@@ -7,13 +7,13 @@ from letta.orm.agent import Agent as AgentModel
7
7
  from letta.orm.errors import NoResultFound
8
8
  from letta.orm.group import Group as GroupModel
9
9
  from letta.orm.message import Message as MessageModel
10
+ from letta.otel.tracing import trace_method
10
11
  from letta.schemas.group import Group as PydanticGroup
11
12
  from letta.schemas.group import GroupCreate, GroupUpdate, ManagerType
12
13
  from letta.schemas.letta_message import LettaMessage
13
14
  from letta.schemas.message import Message as PydanticMessage
14
15
  from letta.schemas.user import User as PydanticUser
15
16
  from letta.server.db import db_registry
16
- from letta.tracing import trace_method
17
17
  from letta.utils import enforce_types
18
18
 
19
19
 
@@ -152,9 +152,9 @@ class GroupManager:
152
152
 
153
153
  @trace_method
154
154
  @enforce_types
155
- def modify_group(self, group_id: str, group_update: GroupUpdate, actor: PydanticUser) -> PydanticGroup:
156
- with db_registry.session() as session:
157
- group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
155
+ async def modify_group_async(self, group_id: str, group_update: GroupUpdate, actor: PydanticUser) -> PydanticGroup:
156
+ async with db_registry.async_session() as session:
157
+ group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor)
158
158
 
159
159
  sleeptime_agent_frequency = None
160
160
  max_message_buffer_length = None
@@ -206,11 +206,11 @@ class GroupManager:
206
206
  if group_update.description:
207
207
  group.description = group_update.description
208
208
  if group_update.agent_ids:
209
- self._process_agent_relationship(
209
+ await self._process_agent_relationship_async(
210
210
  session=session, group=group, agent_ids=group_update.agent_ids, allow_partial=False, replace=True
211
211
  )
212
212
 
213
- group.update(session, actor=actor)
213
+ await group.update_async(session, actor=actor)
214
214
  return group.to_pydantic()
215
215
 
216
216
  @trace_method