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.
- letta/__init__.py +5 -3
- letta/agent.py +3 -2
- letta/agents/base_agent.py +4 -1
- letta/agents/voice_agent.py +1 -0
- letta/constants.py +4 -2
- letta/functions/schema_generator.py +2 -1
- letta/groups/dynamic_multi_agent.py +1 -0
- letta/helpers/converters.py +13 -5
- letta/helpers/json_helpers.py +6 -1
- letta/llm_api/anthropic.py +2 -2
- letta/llm_api/aws_bedrock.py +24 -94
- letta/llm_api/deepseek.py +1 -1
- letta/llm_api/google_ai_client.py +0 -38
- letta/llm_api/google_constants.py +6 -3
- letta/llm_api/helpers.py +1 -1
- letta/llm_api/llm_api_tools.py +4 -7
- letta/llm_api/mistral.py +12 -37
- letta/llm_api/openai.py +17 -17
- letta/llm_api/sample_response_jsons/aws_bedrock.json +38 -0
- letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +15 -0
- letta/llm_api/sample_response_jsons/lmstudio_model_list.json +15 -0
- letta/local_llm/constants.py +2 -23
- letta/local_llm/json_parser.py +11 -1
- letta/local_llm/llm_chat_completion_wrappers/airoboros.py +9 -9
- letta/local_llm/llm_chat_completion_wrappers/chatml.py +7 -8
- letta/local_llm/llm_chat_completion_wrappers/configurable_wrapper.py +6 -6
- letta/local_llm/llm_chat_completion_wrappers/dolphin.py +3 -3
- letta/local_llm/llm_chat_completion_wrappers/simple_summary_wrapper.py +1 -1
- letta/local_llm/ollama/api.py +2 -2
- letta/orm/__init__.py +1 -0
- letta/orm/agent.py +33 -2
- letta/orm/files_agents.py +13 -10
- letta/orm/mixins.py +8 -0
- letta/orm/prompt.py +13 -0
- letta/orm/sqlite_functions.py +61 -17
- letta/otel/db_pool_monitoring.py +13 -12
- letta/schemas/agent.py +69 -4
- letta/schemas/agent_file.py +2 -0
- letta/schemas/block.py +11 -0
- letta/schemas/embedding_config.py +15 -3
- letta/schemas/enums.py +2 -0
- letta/schemas/file.py +1 -1
- letta/schemas/folder.py +74 -0
- letta/schemas/memory.py +12 -6
- letta/schemas/prompt.py +9 -0
- letta/schemas/providers/__init__.py +47 -0
- letta/schemas/providers/anthropic.py +78 -0
- letta/schemas/providers/azure.py +80 -0
- letta/schemas/providers/base.py +201 -0
- letta/schemas/providers/bedrock.py +78 -0
- letta/schemas/providers/cerebras.py +79 -0
- letta/schemas/providers/cohere.py +18 -0
- letta/schemas/providers/deepseek.py +63 -0
- letta/schemas/providers/google_gemini.py +102 -0
- letta/schemas/providers/google_vertex.py +54 -0
- letta/schemas/providers/groq.py +35 -0
- letta/schemas/providers/letta.py +39 -0
- letta/schemas/providers/lmstudio.py +97 -0
- letta/schemas/providers/mistral.py +41 -0
- letta/schemas/providers/ollama.py +151 -0
- letta/schemas/providers/openai.py +241 -0
- letta/schemas/providers/together.py +85 -0
- letta/schemas/providers/vllm.py +57 -0
- letta/schemas/providers/xai.py +66 -0
- letta/server/db.py +0 -5
- letta/server/rest_api/app.py +4 -3
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +152 -4
- letta/server/rest_api/routers/v1/folders.py +490 -0
- letta/server/rest_api/routers/v1/providers.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +21 -26
- letta/server/rest_api/routers/v1/tools.py +90 -15
- letta/server/server.py +50 -95
- letta/services/agent_manager.py +420 -81
- letta/services/agent_serialization_manager.py +707 -0
- letta/services/block_manager.py +132 -11
- letta/services/file_manager.py +104 -29
- letta/services/file_processor/embedder/pinecone_embedder.py +8 -2
- letta/services/file_processor/file_processor.py +75 -24
- letta/services/file_processor/parser/markitdown_parser.py +95 -0
- letta/services/files_agents_manager.py +57 -17
- letta/services/group_manager.py +7 -0
- letta/services/helpers/agent_manager_helper.py +25 -15
- letta/services/provider_manager.py +2 -2
- letta/services/source_manager.py +35 -16
- letta/services/tool_executor/files_tool_executor.py +12 -5
- letta/services/tool_manager.py +12 -0
- letta/services/tool_sandbox/e2b_sandbox.py +52 -48
- letta/settings.py +9 -6
- letta/streaming_utils.py +2 -1
- letta/utils.py +34 -1
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/METADATA +9 -8
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/RECORD +96 -68
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.17.dev20250723104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/entry_points.txt +0 -0
letta/services/block_manager.py
CHANGED
@@ -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
|
-
|
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
|
-
|
218
|
-
|
219
|
-
|
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
|
|
letta/services/file_manager.py
CHANGED
@@ -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
|
-
#
|
42
|
-
|
43
|
-
await self.get_file_by_id.cache_invalidate(self, file_id, actor, include_content=
|
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
|
-
|
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
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
-
|
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
|
-
|
151
|
-
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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.
|
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.
|
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:
|
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(
|
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,
|
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(
|
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",
|
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,
|
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(
|
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
|
-
{
|
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,
|
107
|
-
|
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
|
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(
|
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,
|
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,
|
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,
|
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,
|
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.
|
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,
|
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 []
|