letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721070720__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 (99) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +27 -11
  3. letta/agents/helpers.py +1 -1
  4. letta/agents/letta_agent.py +518 -322
  5. letta/agents/letta_agent_batch.py +1 -2
  6. letta/agents/voice_agent.py +15 -17
  7. letta/client/client.py +3 -3
  8. letta/constants.py +5 -0
  9. letta/embeddings.py +0 -2
  10. letta/errors.py +8 -0
  11. letta/functions/function_sets/base.py +3 -3
  12. letta/functions/helpers.py +2 -3
  13. letta/groups/sleeptime_multi_agent.py +0 -1
  14. letta/helpers/composio_helpers.py +2 -2
  15. letta/helpers/converters.py +1 -1
  16. letta/helpers/pinecone_utils.py +8 -0
  17. letta/helpers/tool_rule_solver.py +13 -18
  18. letta/llm_api/aws_bedrock.py +16 -2
  19. letta/llm_api/cohere.py +1 -1
  20. letta/llm_api/openai_client.py +1 -1
  21. letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
  22. letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
  23. letta/local_llm/utils.py +1 -2
  24. letta/orm/agent.py +3 -3
  25. letta/orm/block.py +4 -4
  26. letta/orm/files_agents.py +0 -1
  27. letta/orm/identity.py +2 -0
  28. letta/orm/mcp_server.py +0 -2
  29. letta/orm/message.py +140 -14
  30. letta/orm/organization.py +5 -5
  31. letta/orm/passage.py +4 -4
  32. letta/orm/source.py +1 -1
  33. letta/orm/sqlalchemy_base.py +61 -39
  34. letta/orm/step.py +2 -0
  35. letta/otel/db_pool_monitoring.py +308 -0
  36. letta/otel/metric_registry.py +94 -1
  37. letta/otel/sqlalchemy_instrumentation.py +548 -0
  38. letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
  39. letta/otel/tracing.py +37 -1
  40. letta/schemas/agent.py +0 -3
  41. letta/schemas/agent_file.py +283 -0
  42. letta/schemas/block.py +0 -3
  43. letta/schemas/file.py +28 -26
  44. letta/schemas/letta_message.py +15 -4
  45. letta/schemas/memory.py +1 -1
  46. letta/schemas/message.py +31 -26
  47. letta/schemas/openai/chat_completion_response.py +0 -1
  48. letta/schemas/providers.py +20 -0
  49. letta/schemas/source.py +11 -13
  50. letta/schemas/step.py +12 -0
  51. letta/schemas/tool.py +0 -4
  52. letta/serialize_schemas/marshmallow_agent.py +14 -1
  53. letta/serialize_schemas/marshmallow_block.py +23 -1
  54. letta/serialize_schemas/marshmallow_message.py +1 -3
  55. letta/serialize_schemas/marshmallow_tool.py +23 -1
  56. letta/server/db.py +110 -6
  57. letta/server/rest_api/app.py +85 -73
  58. letta/server/rest_api/routers/v1/agents.py +68 -53
  59. letta/server/rest_api/routers/v1/blocks.py +2 -2
  60. letta/server/rest_api/routers/v1/jobs.py +3 -0
  61. letta/server/rest_api/routers/v1/organizations.py +2 -2
  62. letta/server/rest_api/routers/v1/sources.py +18 -2
  63. letta/server/rest_api/routers/v1/tools.py +11 -12
  64. letta/server/rest_api/routers/v1/users.py +1 -1
  65. letta/server/rest_api/streaming_response.py +13 -5
  66. letta/server/rest_api/utils.py +8 -25
  67. letta/server/server.py +11 -4
  68. letta/server/ws_api/server.py +2 -2
  69. letta/services/agent_file_manager.py +616 -0
  70. letta/services/agent_manager.py +133 -46
  71. letta/services/block_manager.py +38 -17
  72. letta/services/file_manager.py +106 -21
  73. letta/services/file_processor/file_processor.py +93 -0
  74. letta/services/files_agents_manager.py +28 -0
  75. letta/services/group_manager.py +4 -5
  76. letta/services/helpers/agent_manager_helper.py +57 -9
  77. letta/services/identity_manager.py +22 -0
  78. letta/services/job_manager.py +210 -91
  79. letta/services/llm_batch_manager.py +9 -6
  80. letta/services/mcp/stdio_client.py +1 -2
  81. letta/services/mcp_manager.py +0 -1
  82. letta/services/message_manager.py +49 -26
  83. letta/services/passage_manager.py +0 -1
  84. letta/services/provider_manager.py +1 -1
  85. letta/services/source_manager.py +114 -5
  86. letta/services/step_manager.py +36 -4
  87. letta/services/telemetry_manager.py +9 -2
  88. letta/services/tool_executor/builtin_tool_executor.py +5 -1
  89. letta/services/tool_executor/core_tool_executor.py +3 -3
  90. letta/services/tool_manager.py +95 -20
  91. letta/services/user_manager.py +4 -12
  92. letta/settings.py +23 -6
  93. letta/system.py +1 -1
  94. letta/utils.py +26 -2
  95. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
  96. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
  97. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
  98. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
  99. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,7 @@
1
1
  from typing import List
2
2
 
3
+ from mistralai import OCRPageObject, OCRResponse, OCRUsageInfo
4
+
3
5
  from letta.log import get_logger
4
6
  from letta.otel.context import get_ctx_attributes
5
7
  from letta.otel.tracing import log_event, trace_method
@@ -221,3 +223,94 @@ class FileProcessor:
221
223
  )
222
224
 
223
225
  return []
226
+
227
+ def _create_ocr_response_from_content(self, content: str):
228
+ """Create minimal OCR response from existing content"""
229
+ return OCRResponse(
230
+ model="import-skip-ocr",
231
+ pages=[
232
+ OCRPageObject(
233
+ index=0,
234
+ markdown=content,
235
+ images=[],
236
+ dimensions=None,
237
+ )
238
+ ],
239
+ usage_info=OCRUsageInfo(pages_processed=1),
240
+ document_annotation=None,
241
+ )
242
+
243
+ @trace_method
244
+ async def process_imported_file(self, file_metadata: FileMetadata, source_id: str) -> List[Passage]:
245
+ """Process an imported file that already has content - skip OCR, do chunking/embedding"""
246
+ filename = file_metadata.file_name
247
+
248
+ if not file_metadata.content:
249
+ logger.warning(f"No content found for imported file {filename}")
250
+ return []
251
+
252
+ content = file_metadata.content
253
+ try:
254
+ # Create OCR response from existing content
255
+ ocr_response = self._create_ocr_response_from_content(content)
256
+
257
+ # Update file status to embedding
258
+ file_metadata = await self.file_manager.update_file_status(
259
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
260
+ )
261
+
262
+ logger.info(f"Chunking imported file content for {filename}")
263
+ log_event("file_processor.import_chunking_started", {"filename": filename, "content_length": len(content)})
264
+
265
+ # Chunk and embed using existing logic
266
+ all_passages = await self._chunk_and_embed_with_fallback(
267
+ file_metadata=file_metadata, ocr_response=ocr_response, source_id=source_id
268
+ )
269
+
270
+ # Create passages in database (unless using Pinecone)
271
+ if not self.using_pinecone:
272
+ all_passages = await self.passage_manager.create_many_source_passages_async(
273
+ passages=all_passages, file_metadata=file_metadata, actor=self.actor
274
+ )
275
+ log_event("file_processor.import_passages_created", {"filename": filename, "total_passages": len(all_passages)})
276
+
277
+ # Update file status to completed
278
+ if not self.using_pinecone:
279
+ await self.file_manager.update_file_status(
280
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
281
+ )
282
+ else:
283
+ await self.file_manager.update_file_status(
284
+ file_id=file_metadata.id, actor=self.actor, total_chunks=len(all_passages), chunks_embedded=0
285
+ )
286
+
287
+ logger.info(f"Successfully processed imported file {filename}: {len(all_passages)} passages")
288
+ log_event(
289
+ "file_processor.import_processing_completed",
290
+ {
291
+ "filename": filename,
292
+ "file_id": str(file_metadata.id),
293
+ "total_passages": len(all_passages),
294
+ "status": FileProcessingStatus.COMPLETED.value,
295
+ },
296
+ )
297
+
298
+ return all_passages
299
+
300
+ except Exception as e:
301
+ logger.exception("Import file processing failed for %s: %s", filename, e)
302
+ log_event(
303
+ "file_processor.import_processing_failed",
304
+ {
305
+ "filename": filename,
306
+ "file_id": str(file_metadata.id),
307
+ "error": str(e),
308
+ "error_type": type(e).__name__,
309
+ "status": FileProcessingStatus.ERROR.value,
310
+ },
311
+ )
312
+ await self.file_manager.update_file_status(
313
+ file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e)
314
+ )
315
+
316
+ return []
@@ -573,3 +573,31 @@ class FileAgentManager:
573
573
  if not assoc:
574
574
  raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_name={file_name}) not found in org {actor.organization_id}")
575
575
  return assoc
576
+
577
+ @enforce_types
578
+ @trace_method
579
+ async def get_files_agents_for_agents_async(self, agent_ids: List[str], actor: PydanticUser) -> List[PydanticFileAgent]:
580
+ """
581
+ Get all file-agent relationships for multiple agents in a single query.
582
+
583
+ Args:
584
+ agent_ids: List of agent IDs to find file-agent relationships for
585
+ actor: User performing the action
586
+
587
+ Returns:
588
+ List[PydanticFileAgent]: List of file-agent relationships for these agents
589
+ """
590
+ if not agent_ids:
591
+ return []
592
+
593
+ async with db_registry.async_session() as session:
594
+ query = select(FileAgentModel).where(
595
+ FileAgentModel.agent_id.in_(agent_ids),
596
+ FileAgentModel.organization_id == actor.organization_id,
597
+ FileAgentModel.is_deleted == False,
598
+ )
599
+
600
+ result = await session.execute(query)
601
+ file_agents_orm = result.scalars().all()
602
+
603
+ return [file_agent.to_pydantic() for file_agent in file_agents_orm]
@@ -18,7 +18,6 @@ from letta.utils import enforce_types
18
18
 
19
19
 
20
20
  class GroupManager:
21
-
22
21
  @enforce_types
23
22
  @trace_method
24
23
  def list_groups(
@@ -164,7 +163,7 @@ class GroupManager:
164
163
  manager_agent_id = None
165
164
  if group_update.manager_config:
166
165
  if group_update.manager_config.manager_type != group.manager_type:
167
- raise ValueError(f"Cannot change group pattern after creation")
166
+ raise ValueError("Cannot change group pattern after creation")
168
167
  match group_update.manager_config.manager_type:
169
168
  case ManagerType.round_robin:
170
169
  max_turns = group_update.manager_config.max_turns
@@ -473,7 +472,7 @@ class GroupManager:
473
472
  # 1) require both-or-none
474
473
  if (max_value is None) != (min_value is None):
475
474
  raise ValueError(
476
- f"Both '{max_name}' and '{min_name}' must be provided together " f"(got {max_name}={max_value}, {min_name}={min_value})"
475
+ f"Both '{max_name}' and '{min_name}' must be provided together (got {max_name}={max_value}, {min_name}={min_value})"
477
476
  )
478
477
 
479
478
  # no further checks if neither is provided
@@ -488,9 +487,9 @@ class GroupManager:
488
487
  )
489
488
  if max_value <= 4 or min_value <= 4:
490
489
  raise ValueError(
491
- f"Both '{max_name}' and '{min_name}' must be greater than 4 " f"(got {max_name}={max_value}, {min_name}={min_value})"
490
+ f"Both '{max_name}' and '{min_name}' must be greater than 4 (got {max_name}={max_value}, {min_name}={min_value})"
492
491
  )
493
492
 
494
493
  # 3) ordering
495
494
  if max_value <= min_value:
496
- raise ValueError(f"'{max_name}' must be greater than '{min_name}' " f"(got {max_name}={max_value} <= {min_name}={min_value})")
495
+ raise ValueError(f"'{max_name}' must be greater than '{min_name}' (got {max_name}={max_value} <= {min_name}={min_value})")
@@ -4,6 +4,7 @@ from typing import List, Literal, Optional, Set
4
4
 
5
5
  import numpy as np
6
6
  from sqlalchemy import Select, and_, asc, desc, func, literal, nulls_last, or_, select, union_all
7
+ from sqlalchemy.orm import noload
7
8
  from sqlalchemy.sql.expression import exists
8
9
 
9
10
  from letta import system
@@ -37,7 +38,7 @@ from letta.schemas.memory import Memory
37
38
  from letta.schemas.message import Message, MessageCreate
38
39
  from letta.schemas.tool_rule import ToolRule
39
40
  from letta.schemas.user import User
40
- from letta.settings import settings
41
+ from letta.settings import DatabaseChoice, settings
41
42
  from letta.system import get_initial_boot_messages, get_login_event, package_function_response
42
43
 
43
44
 
@@ -382,7 +383,6 @@ def package_initial_message_sequence(
382
383
  role=message_create.role,
383
384
  content=[TextContent(text=packed_message)],
384
385
  name=message_create.name,
385
- organization_id=actor.organization_id,
386
386
  agent_id=agent_id,
387
387
  model=model,
388
388
  )
@@ -397,7 +397,6 @@ def package_initial_message_sequence(
397
397
  role=message_create.role,
398
398
  content=[TextContent(text=packed_message)],
399
399
  name=message_create.name,
400
- organization_id=actor.organization_id,
401
400
  agent_id=agent_id,
402
401
  model=model,
403
402
  )
@@ -418,7 +417,6 @@ def package_initial_message_sequence(
418
417
  role=MessageRole.assistant,
419
418
  content=None,
420
419
  name=message_create.name,
421
- organization_id=actor.organization_id,
422
420
  agent_id=agent_id,
423
421
  model=model,
424
422
  tool_calls=[
@@ -438,7 +436,6 @@ def package_initial_message_sequence(
438
436
  role=MessageRole.tool,
439
437
  content=[TextContent(text=function_response)],
440
438
  name=message_create.name,
441
- organization_id=actor.organization_id,
442
439
  agent_id=agent_id,
443
440
  model=model,
444
441
  tool_call_id=tool_call_id,
@@ -551,6 +548,9 @@ async def _apply_pagination_async(
551
548
  result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == after))).first()
552
549
  if result:
553
550
  after_sort_value, after_id = result
551
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
552
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime):
553
+ after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
554
554
  query = query.where(
555
555
  _cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending, nulls_last=sort_nulls_last)
556
556
  )
@@ -559,6 +559,9 @@ async def _apply_pagination_async(
559
559
  result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == before))).first()
560
560
  if result:
561
561
  before_sort_value, before_id = result
562
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
563
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime):
564
+ before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
562
565
  query = query.where(
563
566
  _cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending, nulls_last=sort_nulls_last)
564
567
  )
@@ -649,7 +652,12 @@ def _apply_filters(
649
652
  query = query.where(AgentModel.name == name)
650
653
  # Apply a case-insensitive partial match for the agent's name.
651
654
  if query_text:
652
- query = query.where(AgentModel.name.ilike(f"%{query_text}%"))
655
+ if settings.database_engine is DatabaseChoice.POSTGRES:
656
+ # PostgreSQL: Use ILIKE for case-insensitive search
657
+ query = query.where(AgentModel.name.ilike(f"%{query_text}%"))
658
+ else:
659
+ # SQLite: Use LIKE with LOWER for case-insensitive search
660
+ query = query.where(func.lower(AgentModel.name).like(func.lower(f"%{query_text}%")))
653
661
  # Filter agents by project ID.
654
662
  if project_id:
655
663
  query = query.where(AgentModel.project_id == project_id)
@@ -662,6 +670,24 @@ def _apply_filters(
662
670
  return query
663
671
 
664
672
 
673
+ def _apply_relationship_filters(query, include_relationships: Optional[List[str]] = None):
674
+ if include_relationships is None:
675
+ return query
676
+
677
+ if "memory" not in include_relationships:
678
+ query = query.options(noload(AgentModel.core_memory), noload(AgentModel.file_agents))
679
+ if "identity_ids" not in include_relationships:
680
+ query = query.options(noload(AgentModel.identities))
681
+
682
+ relationships = ["tool_exec_environment_variables", "tools", "sources", "tags", "multi_agent_group"]
683
+
684
+ for rel in relationships:
685
+ if rel not in include_relationships:
686
+ query = query.options(noload(getattr(AgentModel, rel)))
687
+
688
+ return query
689
+
690
+
665
691
  def build_passage_query(
666
692
  actor: User,
667
693
  agent_id: Optional[str] = None,
@@ -790,7 +816,7 @@ def build_passage_query(
790
816
 
791
817
  # Vector search
792
818
  if embedded_text:
793
- if settings.letta_pg_uri_no_default:
819
+ if settings.database_engine is DatabaseChoice.POSTGRES:
794
820
  # PostgreSQL with pgvector
795
821
  main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc())
796
822
  else:
@@ -917,7 +943,7 @@ def build_source_passage_query(
917
943
 
918
944
  # Handle text search or vector search
919
945
  if embedded_text:
920
- if settings.letta_pg_uri_no_default:
946
+ if settings.database_engine is DatabaseChoice.POSTGRES:
921
947
  # PostgreSQL with pgvector
922
948
  query = query.order_by(SourcePassage.embedding.cosine_distance(embedded_text).asc())
923
949
  else:
@@ -1004,7 +1030,7 @@ def build_agent_passage_query(
1004
1030
 
1005
1031
  # Handle text search or vector search
1006
1032
  if embedded_text:
1007
- if settings.letta_pg_uri_no_default:
1033
+ if settings.database_engine is DatabaseChoice.POSTGRES:
1008
1034
  # PostgreSQL with pgvector
1009
1035
  query = query.order_by(AgentPassage.embedding.cosine_distance(embedded_text).asc())
1010
1036
  else:
@@ -1070,3 +1096,25 @@ def calculate_multi_agent_tools() -> Set[str]:
1070
1096
  return set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
1071
1097
  else:
1072
1098
  return set(MULTI_AGENT_TOOLS)
1099
+
1100
+
1101
+ @trace_method
1102
+ async def validate_agent_exists_async(session, agent_id: str, actor: User) -> None:
1103
+ """
1104
+ Validate that an agent exists and user has access to it using raw SQL for efficiency.
1105
+
1106
+ Args:
1107
+ session: Database session
1108
+ agent_id: ID of the agent to validate
1109
+ actor: User performing the action
1110
+
1111
+ Raises:
1112
+ NoResultFound: If agent doesn't exist or user doesn't have access
1113
+ """
1114
+ agent_exists_query = select(
1115
+ exists().where(and_(AgentModel.id == agent_id, AgentModel.organization_id == actor.organization_id, AgentModel.is_deleted == False))
1116
+ )
1117
+ result = await session.execute(agent_exists_query)
1118
+
1119
+ if not result.scalar():
1120
+ raise NoResultFound(f"Agent with ID {agent_id} not found")
@@ -6,12 +6,14 @@ from sqlalchemy.exc import NoResultFound
6
6
 
7
7
  from letta.orm.agent import Agent as AgentModel
8
8
  from letta.orm.block import Block as BlockModel
9
+ from letta.orm.errors import UniqueConstraintViolationError
9
10
  from letta.orm.identity import Identity as IdentityModel
10
11
  from letta.otel.tracing import trace_method
11
12
  from letta.schemas.identity import Identity as PydanticIdentity
12
13
  from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityType, IdentityUpdate, IdentityUpsert
13
14
  from letta.schemas.user import User as PydanticUser
14
15
  from letta.server.db import db_registry
16
+ from letta.settings import DatabaseChoice, settings
15
17
  from letta.utils import enforce_types
16
18
 
17
19
 
@@ -64,6 +66,26 @@ class IdentityManager:
64
66
  async def _create_identity_async(self, db_session, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
65
67
  new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids", "block_ids"}, exclude_unset=True))
66
68
  new_identity.organization_id = actor.organization_id
69
+
70
+ # For SQLite compatibility: check for unique constraint violation manually
71
+ # since SQLite doesn't support postgresql_nulls_not_distinct=True
72
+ if settings.database_engine is DatabaseChoice.SQLITE:
73
+ # Check if an identity with the same identifier_key, project_id, and organization_id exists
74
+ query = select(IdentityModel).where(
75
+ IdentityModel.identifier_key == new_identity.identifier_key,
76
+ IdentityModel.project_id == new_identity.project_id,
77
+ IdentityModel.organization_id == new_identity.organization_id,
78
+ )
79
+ result = await db_session.execute(query)
80
+ existing_identity = result.scalar_one_or_none()
81
+ if existing_identity is not None:
82
+ raise UniqueConstraintViolationError(
83
+ f"A unique constraint was violated for Identity. "
84
+ f"An identity with identifier_key='{new_identity.identifier_key}', "
85
+ f"project_id='{new_identity.project_id}', and "
86
+ f"organization_id='{new_identity.organization_id}' already exists."
87
+ )
88
+
67
89
  await self._process_relationship_async(
68
90
  db_session=db_session,
69
91
  identity=new_identity,