letta-nightly 0.8.17.dev20250723104501__py3-none-any.whl → 0.9.0.dev20250724081419__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 (96) hide show
  1. letta/__init__.py +5 -3
  2. letta/agent.py +3 -2
  3. letta/agents/base_agent.py +4 -1
  4. letta/agents/voice_agent.py +1 -0
  5. letta/constants.py +4 -2
  6. letta/functions/schema_generator.py +2 -1
  7. letta/groups/dynamic_multi_agent.py +1 -0
  8. letta/helpers/converters.py +13 -5
  9. letta/helpers/json_helpers.py +6 -1
  10. letta/llm_api/anthropic.py +2 -2
  11. letta/llm_api/aws_bedrock.py +24 -94
  12. letta/llm_api/deepseek.py +1 -1
  13. letta/llm_api/google_ai_client.py +0 -38
  14. letta/llm_api/google_constants.py +6 -3
  15. letta/llm_api/helpers.py +1 -1
  16. letta/llm_api/llm_api_tools.py +4 -7
  17. letta/llm_api/mistral.py +12 -37
  18. letta/llm_api/openai.py +17 -17
  19. letta/llm_api/sample_response_jsons/aws_bedrock.json +38 -0
  20. letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +15 -0
  21. letta/llm_api/sample_response_jsons/lmstudio_model_list.json +15 -0
  22. letta/local_llm/constants.py +2 -23
  23. letta/local_llm/json_parser.py +11 -1
  24. letta/local_llm/llm_chat_completion_wrappers/airoboros.py +9 -9
  25. letta/local_llm/llm_chat_completion_wrappers/chatml.py +7 -8
  26. letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py +6 -6
  27. letta/local_llm/llm_chat_completion_wrappers/dolphin.py +3 -3
  28. letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py +1 -1
  29. letta/local_llm/ollama/api.py +2 -2
  30. letta/orm/__init__.py +1 -0
  31. letta/orm/agent.py +33 -2
  32. letta/orm/files_agents.py +13 -10
  33. letta/orm/mixins.py +8 -0
  34. letta/orm/prompt.py +13 -0
  35. letta/orm/sqlite_functions.py +61 -17
  36. letta/otel/db_pool_monitoring.py +13 -12
  37. letta/schemas/agent.py +69 -4
  38. letta/schemas/agent_file.py +2 -0
  39. letta/schemas/block.py +11 -0
  40. letta/schemas/embedding_config.py +15 -3
  41. letta/schemas/enums.py +2 -0
  42. letta/schemas/file.py +1 -1
  43. letta/schemas/folder.py +74 -0
  44. letta/schemas/memory.py +12 -6
  45. letta/schemas/prompt.py +9 -0
  46. letta/schemas/providers/__init__.py +47 -0
  47. letta/schemas/providers/anthropic.py +78 -0
  48. letta/schemas/providers/azure.py +80 -0
  49. letta/schemas/providers/base.py +201 -0
  50. letta/schemas/providers/bedrock.py +78 -0
  51. letta/schemas/providers/cerebras.py +79 -0
  52. letta/schemas/providers/cohere.py +18 -0
  53. letta/schemas/providers/deepseek.py +63 -0
  54. letta/schemas/providers/google_gemini.py +102 -0
  55. letta/schemas/providers/google_vertex.py +54 -0
  56. letta/schemas/providers/groq.py +35 -0
  57. letta/schemas/providers/letta.py +39 -0
  58. letta/schemas/providers/lmstudio.py +97 -0
  59. letta/schemas/providers/mistral.py +41 -0
  60. letta/schemas/providers/ollama.py +151 -0
  61. letta/schemas/providers/openai.py +241 -0
  62. letta/schemas/providers/together.py +85 -0
  63. letta/schemas/providers/vllm.py +57 -0
  64. letta/schemas/providers/xai.py +66 -0
  65. letta/server/db.py +0 -5
  66. letta/server/rest_api/app.py +4 -3
  67. letta/server/rest_api/routers/v1/__init__.py +2 -0
  68. letta/server/rest_api/routers/v1/agents.py +152 -4
  69. letta/server/rest_api/routers/v1/folders.py +490 -0
  70. letta/server/rest_api/routers/v1/providers.py +2 -2
  71. letta/server/rest_api/routers/v1/sources.py +21 -26
  72. letta/server/rest_api/routers/v1/tools.py +90 -15
  73. letta/server/server.py +50 -95
  74. letta/services/agent_manager.py +420 -81
  75. letta/services/agent_serialization_manager.py +707 -0
  76. letta/services/block_manager.py +132 -11
  77. letta/services/file_manager.py +104 -29
  78. letta/services/file_processor/embedder/pinecone_embedder.py +8 -2
  79. letta/services/file_processor/file_processor.py +75 -24
  80. letta/services/file_processor/parser/markitdown_parser.py +95 -0
  81. letta/services/files_agents_manager.py +57 -17
  82. letta/services/group_manager.py +7 -0
  83. letta/services/helpers/agent_manager_helper.py +25 -15
  84. letta/services/provider_manager.py +2 -2
  85. letta/services/source_manager.py +35 -16
  86. letta/services/tool_executor/files_tool_executor.py +12 -5
  87. letta/services/tool_manager.py +12 -0
  88. letta/services/tool_sandbox/e2b_sandbox.py +52 -48
  89. letta/settings.py +9 -6
  90. letta/streaming_utils.py +2 -1
  91. letta/utils.py +34 -1
  92. {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/METADATA +9 -8
  93. {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/RECORD +96 -68
  94. {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/LICENSE +0 -0
  95. {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/WHEEL +0 -0
  96. {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,8 @@
1
1
  import asyncio
2
+ from datetime import datetime
2
3
  from typing import Dict, List, Optional
3
4
 
4
- from sqlalchemy import delete, select
5
+ from sqlalchemy import delete, or_, select
5
6
  from sqlalchemy.orm import Session
6
7
 
7
8
  from letta.log import get_logger
@@ -17,6 +18,7 @@ from letta.schemas.block import BlockUpdate
17
18
  from letta.schemas.enums import ActorType
18
19
  from letta.schemas.user import User as PydanticUser
19
20
  from letta.server.db import db_registry
21
+ from letta.settings import DatabaseChoice, settings
20
22
  from letta.utils import enforce_types
21
23
 
22
24
  logger = get_logger(__name__)
@@ -176,7 +178,10 @@ class BlockManager:
176
178
  template_name: Optional[str] = None,
177
179
  identity_id: Optional[str] = None,
178
180
  identifier_keys: Optional[List[str]] = None,
181
+ before: Optional[str] = None,
182
+ after: Optional[str] = None,
179
183
  limit: Optional[int] = 50,
184
+ ascending: bool = True,
180
185
  ) -> List[PydanticBlock]:
181
186
  """Async version of get_blocks method. Retrieve blocks based on various optional filters."""
182
187
  from sqlalchemy import select
@@ -205,19 +210,67 @@ class BlockManager:
205
210
  if template_name:
206
211
  query = query.where(BlockModel.template_name == template_name)
207
212
 
213
+ needs_distinct = False
214
+
208
215
  if identifier_keys:
209
- query = (
210
- query.join(BlockModel.identities)
211
- .filter(BlockModel.identities.property.mapper.class_.identifier_key.in_(identifier_keys))
212
- .distinct(BlockModel.id)
216
+ query = query.join(BlockModel.identities).filter(
217
+ BlockModel.identities.property.mapper.class_.identifier_key.in_(identifier_keys)
213
218
  )
219
+ needs_distinct = True
214
220
 
215
221
  if identity_id:
216
- query = (
217
- query.join(BlockModel.identities)
218
- .filter(BlockModel.identities.property.mapper.class_.id == identity_id)
219
- .distinct(BlockModel.id)
220
- )
222
+ query = query.join(BlockModel.identities).filter(BlockModel.identities.property.mapper.class_.id == identity_id)
223
+ needs_distinct = True
224
+
225
+ if after:
226
+ result = (await session.execute(select(BlockModel.created_at, BlockModel.id).where(BlockModel.id == after))).first()
227
+ if result:
228
+ after_sort_value, after_id = result
229
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
230
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime):
231
+ after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
232
+
233
+ if ascending:
234
+ query = query.where(
235
+ BlockModel.created_at > after_sort_value,
236
+ or_(BlockModel.created_at == after_sort_value, BlockModel.id > after_id),
237
+ )
238
+ else:
239
+ query = query.where(
240
+ BlockModel.created_at < after_sort_value,
241
+ or_(BlockModel.created_at == after_sort_value, BlockModel.id < after_id),
242
+ )
243
+
244
+ if before:
245
+ result = (await session.execute(select(BlockModel.created_at, BlockModel.id).where(BlockModel.id == before))).first()
246
+ if result:
247
+ before_sort_value, before_id = result
248
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
249
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime):
250
+ before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
251
+
252
+ if ascending:
253
+ query = query.where(
254
+ BlockModel.created_at < before_sort_value,
255
+ or_(BlockModel.created_at == before_sort_value, BlockModel.id < before_id),
256
+ )
257
+ else:
258
+ query = query.where(
259
+ BlockModel.created_at > before_sort_value,
260
+ or_(BlockModel.created_at == before_sort_value, BlockModel.id > before_id),
261
+ )
262
+
263
+ # Apply ordering and handle distinct if needed
264
+ if needs_distinct:
265
+ if ascending:
266
+ query = query.distinct(BlockModel.id).order_by(BlockModel.id.asc(), BlockModel.created_at.asc())
267
+ else:
268
+ query = query.distinct(BlockModel.id).order_by(BlockModel.id.desc(), BlockModel.created_at.desc())
269
+ else:
270
+ if ascending:
271
+ query = query.order_by(BlockModel.created_at.asc(), BlockModel.id.asc())
272
+ else:
273
+ query = query.order_by(BlockModel.created_at.desc(), BlockModel.id.desc())
221
274
 
222
275
  # Add limit
223
276
  if limit:
@@ -306,19 +359,87 @@ class BlockManager:
306
359
  block_id: str,
307
360
  actor: PydanticUser,
308
361
  include_relationships: Optional[List[str]] = None,
362
+ before: Optional[str] = None,
363
+ after: Optional[str] = None,
364
+ limit: Optional[int] = 50,
365
+ ascending: bool = True,
309
366
  ) -> List[PydanticAgentState]:
310
367
  """
311
- Retrieve all agents associated with a given block.
368
+ Retrieve all agents associated with a given block with pagination support.
369
+
370
+ Args:
371
+ block_id: ID of the block to get agents for
372
+ actor: User performing the operation
373
+ include_relationships: List of relationships to include in the response
374
+ before: Cursor for pagination (get items before this ID)
375
+ after: Cursor for pagination (get items after this ID)
376
+ limit: Maximum number of items to return
377
+ ascending: Sort order (True for ascending, False for descending)
378
+
379
+ Returns:
380
+ List of agent states associated with the block
312
381
  """
313
382
  async with db_registry.async_session() as session:
383
+ # Start with a basic query
314
384
  query = (
315
385
  select(AgentModel)
316
386
  .where(AgentModel.id.in_(select(BlocksAgents.agent_id).where(BlocksAgents.block_id == block_id)))
317
387
  .where(AgentModel.organization_id == actor.organization_id)
318
388
  )
319
389
 
390
+ # Apply pagination using cursor-based approach
391
+ if after:
392
+ result = (await session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == after))).first()
393
+ if result:
394
+ after_sort_value, after_id = result
395
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
396
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime):
397
+ after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
398
+
399
+ if ascending:
400
+ query = query.where(
401
+ AgentModel.created_at > after_sort_value,
402
+ or_(AgentModel.created_at == after_sort_value, AgentModel.id > after_id),
403
+ )
404
+ else:
405
+ query = query.where(
406
+ AgentModel.created_at < after_sort_value,
407
+ or_(AgentModel.created_at == after_sort_value, AgentModel.id < after_id),
408
+ )
409
+
410
+ if before:
411
+ result = (await session.execute(select(AgentModel.created_at, AgentModel.id).where(AgentModel.id == before))).first()
412
+ if result:
413
+ before_sort_value, before_id = result
414
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
415
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime):
416
+ before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
417
+
418
+ if ascending:
419
+ query = query.where(
420
+ AgentModel.created_at < before_sort_value,
421
+ or_(AgentModel.created_at == before_sort_value, AgentModel.id < before_id),
422
+ )
423
+ else:
424
+ query = query.where(
425
+ AgentModel.created_at > before_sort_value,
426
+ or_(AgentModel.created_at == before_sort_value, AgentModel.id > before_id),
427
+ )
428
+
429
+ # Apply sorting
430
+ if ascending:
431
+ query = query.order_by(AgentModel.created_at.asc(), AgentModel.id.asc())
432
+ else:
433
+ query = query.order_by(AgentModel.created_at.desc(), AgentModel.id.desc())
434
+
435
+ # Apply limit
436
+ if limit:
437
+ query = query.limit(limit)
438
+
439
+ # Execute the query
320
440
  result = await session.execute(query)
321
441
  agents_orm = result.scalars().all()
442
+
322
443
  agents = await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents_orm])
323
444
  return agents
324
445
 
@@ -9,7 +9,6 @@ from sqlalchemy.exc import IntegrityError
9
9
  from sqlalchemy.orm import selectinload
10
10
 
11
11
  from letta.constants import MAX_FILENAME_LENGTH
12
- from letta.helpers.decorators import async_redis_cache
13
12
  from letta.orm.errors import NoResultFound
14
13
  from letta.orm.file import FileContent as FileContentModel
15
14
  from letta.orm.file import FileMetadata as FileMetadataModel
@@ -38,13 +37,14 @@ class FileManager:
38
37
 
39
38
  async def _invalidate_file_caches(self, file_id: str, actor: PydanticUser, original_filename: str = None, source_id: str = None):
40
39
  """Invalidate all caches related to a file."""
41
- # invalidate file content cache (all variants)
42
- await self.get_file_by_id.cache_invalidate(self, file_id, actor, include_content=True)
43
- await self.get_file_by_id.cache_invalidate(self, file_id, actor, include_content=False)
40
+ # TEMPORARILY DISABLED - caching is disabled
41
+ # # invalidate file content cache (all variants)
42
+ # await self.get_file_by_id.cache_invalidate(self, file_id, actor, include_content=True)
43
+ # await self.get_file_by_id.cache_invalidate(self, file_id, actor, include_content=False)
44
44
 
45
- # invalidate filename-based cache if we have the info
46
- if original_filename and source_id:
47
- await self.get_file_by_original_name_and_source.cache_invalidate(self, original_filename, source_id, actor)
45
+ # # invalidate filename-based cache if we have the info
46
+ # if original_filename and source_id:
47
+ # await self.get_file_by_original_name_and_source.cache_invalidate(self, original_filename, source_id, actor)
48
48
 
49
49
  @enforce_types
50
50
  @trace_method
@@ -86,12 +86,12 @@ class FileManager:
86
86
  # TODO: We make actor optional for now, but should most likely be enforced due to security reasons
87
87
  @enforce_types
88
88
  @trace_method
89
- @async_redis_cache(
90
- key_func=lambda self, file_id, actor=None, include_content=False, strip_directory_prefix=False: f"{file_id}:{actor.organization_id if actor else 'none'}:{include_content}:{strip_directory_prefix}",
91
- prefix="file_content",
92
- ttl_s=3600,
93
- model_class=PydanticFileMetadata,
94
- )
89
+ # @async_redis_cache(
90
+ # key_func=lambda self, file_id, actor=None, include_content=False, strip_directory_prefix=False: f"{file_id}:{actor.organization_id if actor else 'none'}:{include_content}:{strip_directory_prefix}",
91
+ # prefix="file_content",
92
+ # ttl_s=3600,
93
+ # model_class=PydanticFileMetadata,
94
+ # )
95
95
  async def get_file_by_id(
96
96
  self, file_id: str, actor: Optional[PydanticUser] = None, *, include_content: bool = False, strip_directory_prefix: bool = False
97
97
  ) -> Optional[PydanticFileMetadata]:
@@ -143,12 +143,31 @@ class FileManager:
143
143
  error_message: Optional[str] = None,
144
144
  total_chunks: Optional[int] = None,
145
145
  chunks_embedded: Optional[int] = None,
146
- ) -> PydanticFileMetadata:
146
+ enforce_state_transitions: bool = True,
147
+ ) -> Optional[PydanticFileMetadata]:
147
148
  """
148
149
  Update processing_status, error_message, total_chunks, and/or chunks_embedded on a FileMetadata row.
149
150
 
150
- * 1st round-trip UPDATE
151
- * 2nd round-trip SELECT fresh row (same as read_async)
151
+ Enforces state transition rules (when enforce_state_transitions=True):
152
+ - PENDING -> PARSING -> EMBEDDING -> COMPLETED (normal flow)
153
+ - Any non-terminal state -> ERROR
154
+ - ERROR and COMPLETED are terminal (no transitions allowed)
155
+
156
+ Args:
157
+ file_id: ID of the file to update
158
+ actor: User performing the update
159
+ processing_status: New processing status to set
160
+ error_message: Error message to set (if any)
161
+ total_chunks: Total number of chunks in the file
162
+ chunks_embedded: Number of chunks already embedded
163
+ enforce_state_transitions: Whether to enforce state transition rules (default: True).
164
+ Set to False to bypass validation for testing or special cases.
165
+
166
+ Returns:
167
+ Updated file metadata, or None if the update was blocked
168
+
169
+ * 1st round-trip → UPDATE with optional state validation
170
+ * 2nd round-trip → SELECT fresh row (same as read_async) if update succeeded
152
171
  """
153
172
 
154
173
  if processing_status is None and error_message is None and total_chunks is None and chunks_embedded is None:
@@ -164,23 +183,79 @@ class FileManager:
164
183
  if chunks_embedded is not None:
165
184
  values["chunks_embedded"] = chunks_embedded
166
185
 
186
+ # validate state transitions before making any database calls
187
+ if enforce_state_transitions and processing_status == FileProcessingStatus.PENDING:
188
+ # PENDING cannot be set after initial creation
189
+ raise ValueError(f"Cannot transition to PENDING state for file {file_id} - PENDING is only valid as initial state")
190
+
167
191
  async with db_registry.async_session() as session:
168
- # Fast in-place update – no ORM hydration
192
+ # build where conditions
193
+ where_conditions = [
194
+ FileMetadataModel.id == file_id,
195
+ FileMetadataModel.organization_id == actor.organization_id,
196
+ ]
197
+
198
+ # only add state transition validation if enforce_state_transitions is True
199
+ if enforce_state_transitions:
200
+ # prevent updates to terminal states (ERROR, COMPLETED)
201
+ where_conditions.append(
202
+ FileMetadataModel.processing_status.notin_([FileProcessingStatus.ERROR, FileProcessingStatus.COMPLETED])
203
+ )
204
+
205
+ if processing_status is not None:
206
+ # enforce specific transitions based on target status
207
+ if processing_status == FileProcessingStatus.PARSING:
208
+ where_conditions.append(FileMetadataModel.processing_status == FileProcessingStatus.PENDING)
209
+ elif processing_status == FileProcessingStatus.EMBEDDING:
210
+ where_conditions.append(FileMetadataModel.processing_status == FileProcessingStatus.PARSING)
211
+ elif processing_status == FileProcessingStatus.COMPLETED:
212
+ where_conditions.append(FileMetadataModel.processing_status == FileProcessingStatus.EMBEDDING)
213
+ # ERROR can be set from any non-terminal state (already handled by terminal check above)
214
+
215
+ # fast in-place update with state validation
169
216
  stmt = (
170
217
  update(FileMetadataModel)
171
- .where(
172
- FileMetadataModel.id == file_id,
173
- FileMetadataModel.organization_id == actor.organization_id,
174
- )
218
+ .where(*where_conditions)
175
219
  .values(**values)
220
+ .returning(FileMetadataModel.id) # return id if update succeeded
176
221
  )
177
- await session.execute(stmt)
222
+ result = await session.execute(stmt)
223
+ updated_id = result.scalar()
224
+
225
+ if not updated_id:
226
+ # update was blocked
227
+ await session.commit()
228
+
229
+ if enforce_state_transitions:
230
+ # update was blocked by state transition rules - raise error
231
+ # fetch current state to provide informative error
232
+ current_file = await FileMetadataModel.read_async(
233
+ db_session=session,
234
+ identifier=file_id,
235
+ actor=actor,
236
+ )
237
+ current_status = current_file.processing_status
238
+
239
+ # build informative error message
240
+ if processing_status is not None:
241
+ if current_status in [FileProcessingStatus.ERROR, FileProcessingStatus.COMPLETED]:
242
+ raise ValueError(
243
+ f"Cannot update file {file_id} status from terminal state {current_status} to {processing_status}"
244
+ )
245
+ else:
246
+ raise ValueError(f"Invalid state transition for file {file_id}: {current_status} -> {processing_status}")
247
+ else:
248
+ raise ValueError(f"Cannot update file {file_id} in terminal state {current_status}")
249
+ else:
250
+ # validation was bypassed but update still failed (e.g., file doesn't exist)
251
+ return None
252
+
178
253
  await session.commit()
179
254
 
180
255
  # invalidate cache for this file
181
256
  await self._invalidate_file_caches(file_id, actor)
182
257
 
183
- # Reload via normal accessor so we return a fully-attached object
258
+ # reload via normal accessor so we return a fully-attached object
184
259
  file_orm = await FileMetadataModel.read_async(
185
260
  db_session=session,
186
261
  identifier=file_id,
@@ -317,12 +392,12 @@ class FileManager:
317
392
 
318
393
  @enforce_types
319
394
  @trace_method
320
- @async_redis_cache(
321
- key_func=lambda self, original_filename, source_id, actor: f"{original_filename}:{source_id}:{actor.organization_id}",
322
- prefix="file_by_name",
323
- ttl_s=3600,
324
- model_class=PydanticFileMetadata,
325
- )
395
+ # @async_redis_cache(
396
+ # key_func=lambda self, original_filename, source_id, actor: f"{original_filename}:{source_id}:{actor.organization_id}",
397
+ # prefix="file_by_name",
398
+ # ttl_s=3600,
399
+ # model_class=PydanticFileMetadata,
400
+ # )
326
401
  async def get_file_by_original_name_and_source(
327
402
  self, original_filename: str, source_id: str, actor: PydanticUser
328
403
  ) -> Optional[PydanticFileMetadata]:
@@ -1,8 +1,9 @@
1
- from typing import List
1
+ from typing import List, Optional
2
2
 
3
3
  from letta.helpers.pinecone_utils import upsert_file_records_to_pinecone_index
4
4
  from letta.log import get_logger
5
5
  from letta.otel.tracing import log_event, trace_method
6
+ from letta.schemas.embedding_config import EmbeddingConfig
6
7
  from letta.schemas.passage import Passage
7
8
  from letta.schemas.user import User
8
9
  from letta.services.file_processor.embedder.base_embedder import BaseEmbedder
@@ -18,10 +19,15 @@ logger = get_logger(__name__)
18
19
  class PineconeEmbedder(BaseEmbedder):
19
20
  """Pinecone-based embedding generation"""
20
21
 
21
- def __init__(self):
22
+ def __init__(self, embedding_config: Optional[EmbeddingConfig] = None):
22
23
  if not PINECONE_AVAILABLE:
23
24
  raise ImportError("Pinecone package is not installed. Install it with: pip install pinecone")
24
25
 
26
+ # set default embedding config if not provided
27
+ if embedding_config is None:
28
+ embedding_config = EmbeddingConfig.default_config(provider="pinecone")
29
+
30
+ self.embedding_config = embedding_config
25
31
  super().__init__()
26
32
 
27
33
  @trace_method
@@ -10,12 +10,12 @@ from letta.schemas.enums import FileProcessingStatus
10
10
  from letta.schemas.file import FileMetadata
11
11
  from letta.schemas.passage import Passage
12
12
  from letta.schemas.user import User
13
- from letta.server.server import SyncServer
13
+ from letta.services.agent_manager import AgentManager
14
14
  from letta.services.file_manager import FileManager
15
15
  from letta.services.file_processor.chunker.line_chunker import LineChunker
16
16
  from letta.services.file_processor.chunker.llama_index_chunker import LlamaIndexChunker
17
17
  from letta.services.file_processor.embedder.base_embedder import BaseEmbedder
18
- from letta.services.file_processor.parser.mistral_parser import MistralFileParser
18
+ from letta.services.file_processor.parser.base_parser import FileParser
19
19
  from letta.services.job_manager import JobManager
20
20
  from letta.services.passage_manager import PassageManager
21
21
  from letta.services.source_manager import SourceManager
@@ -28,7 +28,7 @@ class FileProcessor:
28
28
 
29
29
  def __init__(
30
30
  self,
31
- file_parser: MistralFileParser,
31
+ file_parser: FileParser,
32
32
  embedder: BaseEmbedder,
33
33
  actor: User,
34
34
  using_pinecone: bool,
@@ -42,6 +42,7 @@ class FileProcessor:
42
42
  self.source_manager = SourceManager()
43
43
  self.passage_manager = PassageManager()
44
44
  self.job_manager = JobManager()
45
+ self.agent_manager = AgentManager()
45
46
  self.actor = actor
46
47
  self.using_pinecone = using_pinecone
47
48
 
@@ -50,7 +51,7 @@ class FileProcessor:
50
51
  filename = file_metadata.file_name
51
52
 
52
53
  # Create file-type-specific chunker
53
- text_chunker = LlamaIndexChunker(file_type=file_metadata.file_type)
54
+ text_chunker = LlamaIndexChunker(file_type=file_metadata.file_type, chunk_size=self.embedder.embedding_config.embedding_chunk_size)
54
55
 
55
56
  # First attempt with file-specific chunker
56
57
  try:
@@ -58,18 +59,30 @@ class FileProcessor:
58
59
  for page in ocr_response.pages:
59
60
  chunks = text_chunker.chunk_text(page)
60
61
  if not chunks:
61
- log_event("file_processor.chunking_failed", {"filename": filename, "page_index": ocr_response.pages.index(page)})
62
+ log_event(
63
+ "file_processor.chunking_failed",
64
+ {
65
+ "filename": filename,
66
+ "page_index": ocr_response.pages.index(page),
67
+ },
68
+ )
62
69
  raise ValueError("No chunks created from text")
63
70
  all_chunks.extend(chunks)
64
71
 
65
72
  all_passages = await self.embedder.generate_embedded_passages(
66
- file_id=file_metadata.id, source_id=source_id, chunks=all_chunks, actor=self.actor
73
+ file_id=file_metadata.id,
74
+ source_id=source_id,
75
+ chunks=all_chunks,
76
+ actor=self.actor,
67
77
  )
68
78
  return all_passages
69
79
 
70
80
  except Exception as e:
71
81
  logger.warning(f"Failed to chunk/embed with file-specific chunker for {filename}: {str(e)}. Retrying with default chunker.")
72
- log_event("file_processor.embedding_failed_retrying", {"filename": filename, "error": str(e), "error_type": type(e).__name__})
82
+ log_event(
83
+ "file_processor.embedding_failed_retrying",
84
+ {"filename": filename, "error": str(e), "error_type": type(e).__name__},
85
+ )
73
86
 
74
87
  # Retry with default chunker
75
88
  try:
@@ -80,31 +93,49 @@ class FileProcessor:
80
93
  chunks = text_chunker.default_chunk_text(page)
81
94
  if not chunks:
82
95
  log_event(
83
- "file_processor.default_chunking_failed", {"filename": filename, "page_index": ocr_response.pages.index(page)}
96
+ "file_processor.default_chunking_failed",
97
+ {
98
+ "filename": filename,
99
+ "page_index": ocr_response.pages.index(page),
100
+ },
84
101
  )
85
102
  raise ValueError("No chunks created from text with default chunker")
86
103
  all_chunks.extend(chunks)
87
104
 
88
105
  all_passages = await self.embedder.generate_embedded_passages(
89
- file_id=file_metadata.id, source_id=source_id, chunks=all_chunks, actor=self.actor
106
+ file_id=file_metadata.id,
107
+ source_id=source_id,
108
+ chunks=all_chunks,
109
+ actor=self.actor,
90
110
  )
91
111
  logger.info(f"Successfully generated passages with default chunker for {filename}")
92
- log_event("file_processor.default_chunking_success", {"filename": filename, "total_chunks": len(all_chunks)})
112
+ log_event(
113
+ "file_processor.default_chunking_success",
114
+ {"filename": filename, "total_chunks": len(all_chunks)},
115
+ )
93
116
  return all_passages
94
117
 
95
118
  except Exception as fallback_error:
96
119
  logger.error("Default chunking also failed for %s: %s", filename, fallback_error)
97
120
  log_event(
98
121
  "file_processor.default_chunking_also_failed",
99
- {"filename": filename, "fallback_error": str(fallback_error), "fallback_error_type": type(fallback_error).__name__},
122
+ {
123
+ "filename": filename,
124
+ "fallback_error": str(fallback_error),
125
+ "fallback_error_type": type(fallback_error).__name__,
126
+ },
100
127
  )
101
128
  raise fallback_error
102
129
 
103
130
  # TODO: Factor this function out of SyncServer
104
131
  @trace_method
105
132
  async def process(
106
- self, server: SyncServer, agent_states: List[AgentState], source_id: str, content: bytes, file_metadata: FileMetadata
107
- ) -> List[Passage]:
133
+ self,
134
+ agent_states: list[AgentState],
135
+ source_id: str,
136
+ content: bytes,
137
+ file_metadata: FileMetadata,
138
+ ) -> list[Passage]:
108
139
  filename = file_metadata.file_name
109
140
 
110
141
  # Create file as early as possible with no content
@@ -151,7 +182,7 @@ class FileProcessor:
151
182
  )
152
183
  file_metadata = await self.file_manager.upsert_file_content(file_id=file_metadata.id, text=raw_markdown_text, actor=self.actor)
153
184
 
154
- await server.insert_file_into_context_windows(
185
+ await self.agent_manager.insert_file_into_context_windows(
155
186
  source_id=source_id,
156
187
  file_metadata_with_content=file_metadata,
157
188
  actor=self.actor,
@@ -170,18 +201,28 @@ class FileProcessor:
170
201
  raise ValueError("No text extracted from PDF")
171
202
 
172
203
  logger.info("Chunking extracted text")
173
- log_event("file_processor.chunking_started", {"filename": filename, "pages_to_process": len(ocr_response.pages)})
204
+ log_event(
205
+ "file_processor.chunking_started",
206
+ {"filename": filename, "pages_to_process": len(ocr_response.pages)},
207
+ )
174
208
 
175
209
  # Chunk and embed with fallback logic
176
210
  all_passages = await self._chunk_and_embed_with_fallback(
177
- file_metadata=file_metadata, ocr_response=ocr_response, source_id=source_id
211
+ file_metadata=file_metadata,
212
+ ocr_response=ocr_response,
213
+ source_id=source_id,
178
214
  )
179
215
 
180
216
  if not self.using_pinecone:
181
217
  all_passages = await self.passage_manager.create_many_source_passages_async(
182
- passages=all_passages, file_metadata=file_metadata, actor=self.actor
218
+ passages=all_passages,
219
+ file_metadata=file_metadata,
220
+ actor=self.actor,
221
+ )
222
+ log_event(
223
+ "file_processor.passages_created",
224
+ {"filename": filename, "total_passages": len(all_passages)},
183
225
  )
184
- log_event("file_processor.passages_created", {"filename": filename, "total_passages": len(all_passages)})
185
226
 
186
227
  logger.info(f"Successfully processed {filename}: {len(all_passages)} passages")
187
228
  log_event(
@@ -197,17 +238,22 @@ class FileProcessor:
197
238
  # update job status
198
239
  if not self.using_pinecone:
199
240
  await self.file_manager.update_file_status(
200
- file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
241
+ file_id=file_metadata.id,
242
+ actor=self.actor,
243
+ processing_status=FileProcessingStatus.COMPLETED,
201
244
  )
202
245
  else:
203
246
  await self.file_manager.update_file_status(
204
- file_id=file_metadata.id, actor=self.actor, total_chunks=len(all_passages), chunks_embedded=0
247
+ file_id=file_metadata.id,
248
+ actor=self.actor,
249
+ total_chunks=len(all_passages),
250
+ chunks_embedded=0,
205
251
  )
206
252
 
207
253
  return all_passages
208
254
 
209
255
  except Exception as e:
210
- logger.error("File processing failed for %s: %s", filename, e)
256
+ logger.exception("File processing failed for %s: %s", filename, e)
211
257
  log_event(
212
258
  "file_processor.processing_failed",
213
259
  {
@@ -254,7 +300,7 @@ class FileProcessor:
254
300
  # Create OCR response from existing content
255
301
  ocr_response = self._create_ocr_response_from_content(content)
256
302
 
257
- # Update file status to embedding
303
+ # Update file status to embedding (valid transition from PARSING)
258
304
  file_metadata = await self.file_manager.update_file_status(
259
305
  file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
260
306
  )
@@ -274,12 +320,14 @@ class FileProcessor:
274
320
  )
275
321
  log_event("file_processor.import_passages_created", {"filename": filename, "total_passages": len(all_passages)})
276
322
 
277
- # Update file status to completed
323
+ # Update file status to completed (valid transition from EMBEDDING)
278
324
  if not self.using_pinecone:
279
325
  await self.file_manager.update_file_status(
280
326
  file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
281
327
  )
282
328
  else:
329
+ # For Pinecone, update chunk counts but keep status at EMBEDDING
330
+ # The status will be updated to COMPLETED later when chunks are confirmed embedded
283
331
  await self.file_manager.update_file_status(
284
332
  file_id=file_metadata.id, actor=self.actor, total_chunks=len(all_passages), chunks_embedded=0
285
333
  )
@@ -310,7 +358,10 @@ class FileProcessor:
310
358
  },
311
359
  )
312
360
  await self.file_manager.update_file_status(
313
- file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e)
361
+ file_id=file_metadata.id,
362
+ actor=self.actor,
363
+ processing_status=FileProcessingStatus.ERROR,
364
+ error_message=str(e),
314
365
  )
315
366
 
316
367
  return []