letta-nightly 0.8.15.dev20250719104256__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.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
  96. {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
  97. {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
  98. {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
  99. {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/entry_points.txt +0 -0
@@ -28,7 +28,7 @@ from letta.orm import AgentPassage, AgentsTags
28
28
  from letta.orm import Block as BlockModel
29
29
  from letta.orm import BlocksAgents
30
30
  from letta.orm import Group as GroupModel
31
- from letta.orm import IdentitiesAgents
31
+ from letta.orm import GroupsAgents, IdentitiesAgents
32
32
  from letta.orm import Source as SourceModel
33
33
  from letta.orm import SourcePassage, SourcesAgents
34
34
  from letta.orm import Tool as ToolModel
@@ -71,6 +71,7 @@ from letta.services.helpers.agent_manager_helper import (
71
71
  _apply_identity_filters,
72
72
  _apply_pagination,
73
73
  _apply_pagination_async,
74
+ _apply_relationship_filters,
74
75
  _apply_tag_filter,
75
76
  _process_relationship,
76
77
  _process_relationship_async,
@@ -84,12 +85,14 @@ from letta.services.helpers.agent_manager_helper import (
84
85
  derive_system_message,
85
86
  initialize_message_sequence,
86
87
  package_initial_message_sequence,
88
+ validate_agent_exists_async,
87
89
  )
88
90
  from letta.services.identity_manager import IdentityManager
89
91
  from letta.services.message_manager import MessageManager
90
92
  from letta.services.passage_manager import PassageManager
91
93
  from letta.services.source_manager import SourceManager
92
94
  from letta.services.tool_manager import ToolManager
95
+ from letta.settings import DatabaseChoice, settings
93
96
  from letta.utils import enforce_types, united_diff
94
97
 
95
98
  logger = get_logger(__name__)
@@ -448,7 +451,11 @@ class AgentManager:
448
451
 
449
452
  @trace_method
450
453
  async def create_agent_async(
451
- self, agent_create: CreateAgent, actor: PydanticUser, _test_only_force_id: Optional[str] = None
454
+ self,
455
+ agent_create: CreateAgent,
456
+ actor: PydanticUser,
457
+ _test_only_force_id: Optional[str] = None,
458
+ _init_with_no_messages: bool = False,
452
459
  ) -> PydanticAgentState:
453
460
  # validate required configs
454
461
  if not agent_create.llm_config or not agent_create.embedding_config:
@@ -616,20 +623,36 @@ class AgentManager:
616
623
  ]
617
624
  await session.execute(insert(AgentEnvironmentVariable).values(env_rows))
618
625
 
619
- # initial message sequence
620
- agent_state = await new_agent.to_pydantic_async(include_relationships={"memory"})
621
- init_messages = self._generate_initial_message_sequence(
622
- actor,
623
- agent_state=agent_state,
624
- supplied_initial_message_sequence=agent_create.initial_message_sequence,
625
- )
626
- new_agent.message_ids = [msg.id for msg in init_messages]
627
-
628
- await session.refresh(new_agent)
629
-
630
- result = await new_agent.to_pydantic_async()
626
+ include_relationships = []
627
+ if tool_ids:
628
+ include_relationships.append("tools")
629
+ if source_ids:
630
+ include_relationships.append("sources")
631
+ if block_ids:
632
+ include_relationships.append("memory")
633
+ if identity_ids:
634
+ include_relationships.append("identity_ids")
635
+ if tag_values:
636
+ include_relationships.append("tags")
637
+
638
+ result = await new_agent.to_pydantic_async(include_relationships=include_relationships)
639
+
640
+ # initial message sequence (skip if _init_with_no_messages is True)
641
+ if not _init_with_no_messages:
642
+ init_messages = self._generate_initial_message_sequence(
643
+ actor,
644
+ agent_state=result,
645
+ supplied_initial_message_sequence=agent_create.initial_message_sequence,
646
+ )
647
+ result.message_ids = [msg.id for msg in init_messages]
648
+ new_agent.message_ids = [msg.id for msg in init_messages]
649
+ await new_agent.update_async(session, no_refresh=True)
650
+ else:
651
+ init_messages = []
631
652
 
632
- await self.message_manager.create_many_messages_async(pydantic_msgs=init_messages, actor=actor)
653
+ # Only create messages if we initialized with messages
654
+ if not _init_with_no_messages:
655
+ await self.message_manager.create_many_messages_async(pydantic_msgs=init_messages, actor=actor)
633
656
  return result
634
657
 
635
658
  @enforce_types
@@ -920,6 +943,30 @@ class AgentManager:
920
943
 
921
944
  return await agent.to_pydantic_async()
922
945
 
946
+ @enforce_types
947
+ @trace_method
948
+ async def update_message_ids_async(
949
+ self,
950
+ agent_id: str,
951
+ message_ids: List[str],
952
+ actor: PydanticUser,
953
+ ) -> None:
954
+ async with db_registry.async_session() as session:
955
+ query = select(AgentModel)
956
+ query = AgentModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION)
957
+ query = query.where(AgentModel.id == agent_id)
958
+ query = _apply_relationship_filters(query, include_relationships=[])
959
+
960
+ result = await session.execute(query)
961
+ agent = result.scalar_one_or_none()
962
+
963
+ agent.updated_at = datetime.now(timezone.utc)
964
+ agent.last_updated_by_id = actor.id
965
+ agent.message_ids = message_ids
966
+
967
+ await agent.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
968
+ await session.commit()
969
+
923
970
  # TODO: Make this general and think about how to roll this into sqlalchemybase
924
971
  @trace_method
925
972
  def list_agents(
@@ -1019,7 +1066,8 @@ class AgentManager:
1019
1066
  identity_id (Optional[str]): Filter by identifier ID.
1020
1067
  identifier_keys (Optional[List[str]]): Search agents by identifier keys.
1021
1068
  include_relationships (Optional[List[str]]): List of fields to load for performance optimization.
1022
- ascending
1069
+ ascending (bool): Sort agents in ascending order.
1070
+ sort_by (Optional[str]): Sort agents by this field.
1023
1071
 
1024
1072
  Returns:
1025
1073
  List[PydanticAgentState]: The filtered list of matching agents.
@@ -1032,11 +1080,11 @@ class AgentManager:
1032
1080
  query = _apply_filters(query, name, query_text, project_id, template_id, base_template_id)
1033
1081
  query = _apply_identity_filters(query, identity_id, identifier_keys)
1034
1082
  query = _apply_tag_filter(query, tags, match_all_tags)
1083
+ query = _apply_relationship_filters(query, include_relationships)
1035
1084
  query = await _apply_pagination_async(query, before, after, session, ascending=ascending, sort_by=sort_by)
1036
1085
 
1037
1086
  if limit:
1038
1087
  query = query.limit(limit)
1039
-
1040
1088
  result = await session.execute(query)
1041
1089
  agents = result.scalars().all()
1042
1090
  return await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents])
@@ -1168,10 +1216,23 @@ class AgentManager:
1168
1216
  include_relationships: Optional[List[str]] = None,
1169
1217
  ) -> PydanticAgentState:
1170
1218
  """Fetch an agent by its ID."""
1171
-
1172
1219
  async with db_registry.async_session() as session:
1173
- agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
1174
- return await agent.to_pydantic_async(include_relationships=include_relationships)
1220
+ try:
1221
+ query = select(AgentModel)
1222
+ query = AgentModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION)
1223
+ query = query.where(AgentModel.id == agent_id)
1224
+ query = _apply_relationship_filters(query, include_relationships)
1225
+
1226
+ result = await session.execute(query)
1227
+ agent = result.scalar_one_or_none()
1228
+
1229
+ if agent is None:
1230
+ raise NoResultFound(f"Agent with ID {agent_id} not found")
1231
+
1232
+ return await agent.to_pydantic_async(include_relationships=include_relationships)
1233
+ except Exception as e:
1234
+ logger.error(f"Error fetching agent {agent_id}: {str(e)}")
1235
+ raise
1175
1236
 
1176
1237
  @enforce_types
1177
1238
  @trace_method
@@ -1183,12 +1244,23 @@ class AgentManager:
1183
1244
  ) -> list[PydanticAgentState]:
1184
1245
  """Fetch a list of agents by their IDs."""
1185
1246
  async with db_registry.async_session() as session:
1186
- agents = await AgentModel.read_multiple_async(
1187
- db_session=session,
1188
- identifiers=agent_ids,
1189
- actor=actor,
1190
- )
1191
- return await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents])
1247
+ try:
1248
+ query = select(AgentModel)
1249
+ query = AgentModel.apply_access_predicate(query, actor, ["read"], AccessType.ORGANIZATION)
1250
+ query = query.where(AgentModel.id.in_(agent_ids))
1251
+ query = _apply_relationship_filters(query, include_relationships)
1252
+
1253
+ result = await session.execute(query)
1254
+ agents = result.scalars().all()
1255
+
1256
+ if not agents:
1257
+ logger.warning(f"No agents found with IDs: {agent_ids}")
1258
+ return []
1259
+
1260
+ return await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents])
1261
+ except Exception as e:
1262
+ logger.error(f"Error fetching agents with IDs {agent_ids}: {str(e)}")
1263
+ raise
1192
1264
 
1193
1265
  @enforce_types
1194
1266
  @trace_method
@@ -1334,6 +1406,9 @@ class AgentManager:
1334
1406
  schema = MarshmallowAgentSchema(session=session, actor=actor)
1335
1407
  agent = schema.load(serialized_agent_dict, session=session)
1336
1408
 
1409
+ agent.organization_id = actor.organization_id
1410
+ for block in agent.core_memory:
1411
+ block.organization_id = actor.organization_id
1337
1412
  if append_copy_suffix:
1338
1413
  agent.name += "_copy"
1339
1414
  if project_id:
@@ -1435,10 +1510,17 @@ class AgentManager:
1435
1510
  @trace_method
1436
1511
  def list_groups(self, agent_id: str, actor: PydanticUser, manager_type: Optional[str] = None) -> List[PydanticGroup]:
1437
1512
  with db_registry.session() as session:
1438
- agent = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
1513
+ query = (
1514
+ select(GroupModel)
1515
+ .join(GroupsAgents, GroupModel.id == GroupsAgents.group_id)
1516
+ .where(GroupsAgents.agent_id == agent_id, GroupModel.organization_id == actor.organization_id)
1517
+ )
1518
+
1439
1519
  if manager_type:
1440
- return [group.to_pydantic() for group in agent.groups if group.manager_type == manager_type]
1441
- return [group.to_pydantic() for group in agent.groups]
1520
+ query = query.where(GroupModel.manager_type == manager_type)
1521
+
1522
+ result = session.execute(query)
1523
+ return [group.to_pydantic() for group in result.scalars()]
1442
1524
 
1443
1525
  # ======================================================================================================================
1444
1526
  # In Context Messages Management
@@ -1559,15 +1641,9 @@ class AgentManager:
1559
1641
 
1560
1642
  Updates to the memory header should *not* trigger a rebuild, since that will simply flood recall storage with excess messages
1561
1643
  """
1562
- num_messages_task = self.message_manager.size_async(actor=actor, agent_id=agent_id)
1563
- num_archival_memories_task = self.passage_manager.agent_passage_size_async(actor=actor, agent_id=agent_id)
1564
- agent_state_task = self.get_agent_by_id_async(agent_id=agent_id, include_relationships=["memory", "sources", "tools"], actor=actor)
1565
-
1566
- num_messages, num_archival_memories, agent_state = await asyncio.gather(
1567
- num_messages_task,
1568
- num_archival_memories_task,
1569
- agent_state_task,
1570
- )
1644
+ num_messages = await self.message_manager.size_async(actor=actor, agent_id=agent_id)
1645
+ num_archival_memories = await self.passage_manager.agent_passage_size_async(actor=actor, agent_id=agent_id)
1646
+ agent_state = await self.get_agent_by_id_async(agent_id=agent_id, include_relationships=["memory", "sources", "tools"], actor=actor)
1571
1647
 
1572
1648
  if not tool_rules_solver:
1573
1649
  tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
@@ -1719,7 +1795,7 @@ class AgentManager:
1719
1795
  logger.error(
1720
1796
  f"Agent {agent_id} has no message_ids. Agent details: "
1721
1797
  f"name={agent.name}, created_at={agent.created_at}, "
1722
- f"message_ids={agent.message_ids}, organization_id={agent.organization_id}"
1798
+ f"message_ids={agent.message_ids}, organization_id={actor.organization_id}"
1723
1799
  )
1724
1800
  raise ValueError(f"Agent {agent_id} has no message_ids - cannot preserve system message")
1725
1801
 
@@ -1784,9 +1860,10 @@ class AgentManager:
1784
1860
  )
1785
1861
 
1786
1862
  # refresh memory from DB (using block ids)
1787
- blocks = await asyncio.gather(
1788
- *[self.block_manager.get_block_by_id_async(block.id, actor=actor) for block in agent_state.memory.get_blocks()]
1863
+ blocks = await self.block_manager.get_all_blocks_by_ids_async(
1864
+ block_ids=[b.id for b in agent_state.memory.get_blocks()], actor=actor
1789
1865
  )
1866
+
1790
1867
  agent_state.memory = Memory(
1791
1868
  blocks=blocks,
1792
1869
  file_blocks=agent_state.memory.file_blocks,
@@ -1907,7 +1984,7 @@ class AgentManager:
1907
1984
  """
1908
1985
  async with db_registry.async_session() as session:
1909
1986
  # Validate agent exists and user has access
1910
- await self._validate_agent_exists_async(session, agent_id, actor)
1987
+ await validate_agent_exists_async(session, agent_id, actor)
1911
1988
 
1912
1989
  # Use raw SQL to efficiently fetch sources - much faster than lazy loading
1913
1990
  # Fast query without relationship loading
@@ -1943,7 +2020,7 @@ class AgentManager:
1943
2020
  """
1944
2021
  async with db_registry.async_session() as session:
1945
2022
  # Validate agent exists and user has access
1946
- await self._validate_agent_exists_async(session, agent_id, actor)
2023
+ await validate_agent_exists_async(session, agent_id, actor)
1947
2024
 
1948
2025
  # Check if the source is actually attached to this agent using junction table
1949
2026
  attachment_check_query = select(SourcesAgents).where(SourcesAgents.agent_id == agent_id, SourcesAgents.source_id == source_id)
@@ -2710,7 +2787,7 @@ class AgentManager:
2710
2787
  """
2711
2788
  async with db_registry.async_session() as session:
2712
2789
  # lightweight check for agent access
2713
- await self._validate_agent_exists_async(session, agent_id, actor)
2790
+ await validate_agent_exists_async(session, agent_id, actor)
2714
2791
 
2715
2792
  # direct query for tools via join - much more performant
2716
2793
  query = (
@@ -2752,7 +2829,12 @@ class AgentManager:
2752
2829
  )
2753
2830
 
2754
2831
  if query_text:
2755
- query = query.filter(AgentsTags.tag.ilike(f"%{query_text}%"))
2832
+ if settings.database_engine is DatabaseChoice.POSTGRES:
2833
+ # PostgreSQL: Use ILIKE for case-insensitive search
2834
+ query = query.filter(AgentsTags.tag.ilike(f"%{query_text}%"))
2835
+ else:
2836
+ # SQLite: Use LIKE with LOWER for case-insensitive search
2837
+ query = query.filter(func.lower(AgentsTags.tag).like(func.lower(f"%{query_text}%")))
2756
2838
 
2757
2839
  if after:
2758
2840
  query = query.filter(AgentsTags.tag > after)
@@ -2788,7 +2870,12 @@ class AgentManager:
2788
2870
  )
2789
2871
 
2790
2872
  if query_text:
2791
- query = query.where(AgentsTags.tag.ilike(f"%{query_text}%"))
2873
+ if settings.database_engine is DatabaseChoice.POSTGRES:
2874
+ # PostgreSQL: Use ILIKE for case-insensitive search
2875
+ query = query.where(AgentsTags.tag.ilike(f"%{query_text}%"))
2876
+ else:
2877
+ # SQLite: Use LIKE with LOWER for case-insensitive search
2878
+ query = query.where(func.lower(AgentsTags.tag).like(func.lower(f"%{query_text}%")))
2792
2879
 
2793
2880
  if after:
2794
2881
  query = query.where(AgentsTags.tag > after)
@@ -1,12 +1,14 @@
1
1
  import asyncio
2
2
  from typing import Dict, List, Optional
3
3
 
4
- from sqlalchemy import select
4
+ from sqlalchemy import delete, select
5
5
  from sqlalchemy.orm import Session
6
6
 
7
7
  from letta.log import get_logger
8
+ from letta.orm.agent import Agent as AgentModel
8
9
  from letta.orm.block import Block as BlockModel
9
10
  from letta.orm.block_history import BlockHistory
11
+ from letta.orm.blocks_agents import BlocksAgents
10
12
  from letta.orm.errors import NoResultFound
11
13
  from letta.otel.tracing import trace_method
12
14
  from letta.schemas.agent import AgentState as PydanticAgentState
@@ -50,8 +52,10 @@ class BlockManager:
50
52
  async with db_registry.async_session() as session:
51
53
  data = block.model_dump(to_orm=True, exclude_none=True)
52
54
  block = BlockModel(**data, organization_id=actor.organization_id)
53
- await block.create_async(session, actor=actor)
54
- return block.to_pydantic()
55
+ await block.create_async(session, actor=actor, no_commit=True, no_refresh=True)
56
+ pydantic_block = block.to_pydantic()
57
+ await session.commit()
58
+ return pydantic_block
55
59
 
56
60
  @enforce_types
57
61
  @trace_method
@@ -95,11 +99,12 @@ class BlockManager:
95
99
  block_models = [
96
100
  BlockModel(**block.model_dump(to_orm=True, exclude_none=True), organization_id=actor.organization_id) for block in blocks
97
101
  ]
98
-
99
- created_models = await BlockModel.batch_create_async(items=block_models, db_session=session, actor=actor)
100
-
101
- # Convert back to Pydantic
102
- return [m.to_pydantic() for m in created_models]
102
+ created_models = await BlockModel.batch_create_async(
103
+ items=block_models, db_session=session, actor=actor, no_commit=True, no_refresh=True
104
+ )
105
+ result = [m.to_pydantic() for m in created_models]
106
+ await session.commit()
107
+ return result
103
108
 
104
109
  @enforce_types
105
110
  @trace_method
@@ -130,26 +135,36 @@ class BlockManager:
130
135
  for key, value in update_data.items():
131
136
  setattr(block, key, value)
132
137
 
133
- await block.update_async(db_session=session, actor=actor)
134
- return block.to_pydantic()
138
+ await block.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
139
+ pydantic_block = block.to_pydantic()
140
+ await session.commit()
141
+ return pydantic_block
135
142
 
136
143
  @enforce_types
137
144
  @trace_method
138
- def delete_block(self, block_id: str, actor: PydanticUser) -> PydanticBlock:
145
+ def delete_block(self, block_id: str, actor: PydanticUser) -> None:
139
146
  """Delete a block by its ID."""
140
147
  with db_registry.session() as session:
148
+ # First, delete all references in blocks_agents table
149
+ session.execute(delete(BlocksAgents).where(BlocksAgents.block_id == block_id))
150
+ session.flush()
151
+
152
+ # Then delete the block itself
141
153
  block = BlockModel.read(db_session=session, identifier=block_id)
142
154
  block.hard_delete(db_session=session, actor=actor)
143
- return block.to_pydantic()
144
155
 
145
156
  @enforce_types
146
157
  @trace_method
147
- async def delete_block_async(self, block_id: str, actor: PydanticUser) -> PydanticBlock:
158
+ async def delete_block_async(self, block_id: str, actor: PydanticUser) -> None:
148
159
  """Delete a block by its ID."""
149
160
  async with db_registry.async_session() as session:
161
+ # First, delete all references in blocks_agents table
162
+ await session.execute(delete(BlocksAgents).where(BlocksAgents.block_id == block_id))
163
+ await session.flush()
164
+
165
+ # Then delete the block itself
150
166
  block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
151
167
  await block.hard_delete_async(db_session=session, actor=actor)
152
- return block.to_pydantic()
153
168
 
154
169
  @enforce_types
155
170
  @trace_method
@@ -296,8 +311,14 @@ class BlockManager:
296
311
  Retrieve all agents associated with a given block.
297
312
  """
298
313
  async with db_registry.async_session() as session:
299
- block = await BlockModel.read_async(db_session=session, identifier=block_id, actor=actor)
300
- agents_orm = block.agents
314
+ query = (
315
+ select(AgentModel)
316
+ .where(AgentModel.id.in_(select(BlocksAgents.agent_id).where(BlocksAgents.block_id == block_id)))
317
+ .where(AgentModel.organization_id == actor.organization_id)
318
+ )
319
+
320
+ result = await session.execute(query)
321
+ agents_orm = result.scalars().all()
301
322
  agents = await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents_orm])
302
323
  return agents
303
324
 
@@ -531,7 +552,7 @@ class BlockManager:
531
552
  for block in blocks:
532
553
  new_val = updates[block.id]
533
554
  if len(new_val) > block.limit:
534
- logger.warning(f"Value length ({len(new_val)}) exceeds limit " f"({block.limit}) for block {block.id!r}, truncating...")
555
+ logger.warning(f"Value length ({len(new_val)}) exceeds limit ({block.limit}) for block {block.id!r}, truncating...")
535
556
  new_val = new_val[: block.limit]
536
557
  block.value = new_val
537
558
 
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import os
2
3
  from datetime import datetime
3
4
  from typing import List, Optional
@@ -357,7 +358,9 @@ class FileManager:
357
358
 
358
359
  @enforce_types
359
360
  @trace_method
360
- async def get_organization_sources_metadata(self, actor: PydanticUser) -> OrganizationSourcesStats:
361
+ async def get_organization_sources_metadata(
362
+ self, actor: PydanticUser, include_detailed_per_source_metadata: bool = False
363
+ ) -> OrganizationSourcesStats:
361
364
  """
362
365
  Get aggregated metadata for all sources in an organization with optimized queries.
363
366
 
@@ -365,7 +368,7 @@ class FileManager:
365
368
  - Total number of sources
366
369
  - Total number of files across all sources
367
370
  - Total size of all files
368
- - Per-source breakdown with file details
371
+ - Per-source breakdown with file details (if include_detailed_per_source_metadata is True)
369
372
  """
370
373
  async with db_registry.async_session() as session:
371
374
  # Import here to avoid circular imports
@@ -395,31 +398,113 @@ class FileManager:
395
398
  for row in source_aggregations:
396
399
  source_id, source_name, file_count, total_size = row
397
400
 
398
- # Get individual file details for this source
399
- files_query = (
400
- select(FileMetadataModel.id, FileMetadataModel.file_name, FileMetadataModel.file_size)
401
- .where(
402
- FileMetadataModel.source_id == source_id,
403
- FileMetadataModel.organization_id == actor.organization_id,
404
- FileMetadataModel.is_deleted == False,
401
+ if include_detailed_per_source_metadata:
402
+ # Get individual file details for this source
403
+ files_query = (
404
+ select(FileMetadataModel.id, FileMetadataModel.file_name, FileMetadataModel.file_size)
405
+ .where(
406
+ FileMetadataModel.source_id == source_id,
407
+ FileMetadataModel.organization_id == actor.organization_id,
408
+ FileMetadataModel.is_deleted == False,
409
+ )
410
+ .order_by(FileMetadataModel.file_name)
405
411
  )
406
- .order_by(FileMetadataModel.file_name)
407
- )
408
412
 
409
- files_result = await session.execute(files_query)
410
- files_rows = files_result.fetchall()
413
+ files_result = await session.execute(files_query)
414
+ files_rows = files_result.fetchall()
411
415
 
412
- # Build file stats
413
- files = [FileStats(file_id=file_row[0], file_name=file_row[1], file_size=file_row[2]) for file_row in files_rows]
416
+ # Build file stats
417
+ files = [FileStats(file_id=file_row[0], file_name=file_row[1], file_size=file_row[2]) for file_row in files_rows]
414
418
 
415
- # Build source metadata
416
- source_metadata = SourceStats(
417
- source_id=source_id, source_name=source_name, file_count=file_count, total_size=total_size, files=files
418
- )
419
+ # Build source metadata
420
+ source_metadata = SourceStats(
421
+ source_id=source_id, source_name=source_name, file_count=file_count, total_size=total_size, files=files
422
+ )
423
+
424
+ metadata.sources.append(source_metadata)
419
425
 
420
- metadata.sources.append(source_metadata)
421
426
  metadata.total_files += file_count
422
427
  metadata.total_size += total_size
423
428
 
424
- metadata.total_sources = len(metadata.sources)
429
+ metadata.total_sources = len(source_aggregations)
425
430
  return metadata
431
+
432
+ @enforce_types
433
+ @trace_method
434
+ async def get_files_by_ids_async(
435
+ self, file_ids: List[str], actor: PydanticUser, *, include_content: bool = False
436
+ ) -> List[PydanticFileMetadata]:
437
+ """
438
+ Get multiple files by their IDs in a single query.
439
+
440
+ Args:
441
+ file_ids: List of file IDs to retrieve
442
+ actor: User performing the action
443
+ include_content: Whether to include file content in the response
444
+
445
+ Returns:
446
+ List[PydanticFileMetadata]: List of files (may be fewer than requested if some don't exist)
447
+ """
448
+ if not file_ids:
449
+ return []
450
+
451
+ async with db_registry.async_session() as session:
452
+ query = select(FileMetadataModel).where(
453
+ FileMetadataModel.id.in_(file_ids),
454
+ FileMetadataModel.organization_id == actor.organization_id,
455
+ FileMetadataModel.is_deleted == False,
456
+ )
457
+
458
+ # Eagerly load content if requested
459
+ if include_content:
460
+ query = query.options(selectinload(FileMetadataModel.content))
461
+
462
+ result = await session.execute(query)
463
+ files_orm = result.scalars().all()
464
+
465
+ return await asyncio.gather(*[file.to_pydantic_async(include_content=include_content) for file in files_orm])
466
+
467
+ @enforce_types
468
+ @trace_method
469
+ async def get_files_for_agents_async(
470
+ self, agent_ids: List[str], actor: PydanticUser, *, include_content: bool = False
471
+ ) -> List[PydanticFileMetadata]:
472
+ """
473
+ Get all files associated with the given agents via file-agent relationships.
474
+
475
+ Args:
476
+ agent_ids: List of agent IDs to find files for
477
+ actor: User performing the action
478
+ include_content: Whether to include file content in the response
479
+
480
+ Returns:
481
+ List[PydanticFileMetadata]: List of unique files associated with these agents
482
+ """
483
+ if not agent_ids:
484
+ return []
485
+
486
+ async with db_registry.async_session() as session:
487
+ # We need to import FileAgent here to avoid circular imports
488
+ from letta.orm.file_agent import FileAgent as FileAgentModel
489
+
490
+ # Join through file-agent relationships
491
+ query = (
492
+ select(FileMetadataModel)
493
+ .join(FileAgentModel, FileMetadataModel.id == FileAgentModel.file_id)
494
+ .where(
495
+ FileAgentModel.agent_id.in_(agent_ids),
496
+ FileMetadataModel.organization_id == actor.organization_id,
497
+ FileMetadataModel.is_deleted == False,
498
+ FileAgentModel.is_deleted == False,
499
+ )
500
+ .distinct() # Ensure we don't get duplicate files
501
+ )
502
+
503
+ # Eagerly load content if requested
504
+ if include_content:
505
+ query = query.options(selectinload(FileMetadataModel.content))
506
+
507
+ result = await session.execute(query)
508
+ files_orm = result.scalars().all()
509
+
510
+ return await asyncio.gather(*[file.to_pydantic_async(include_content=include_content) for file in files_orm])