letta-nightly 0.8.17.dev20250722104501__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.dev20250722104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/METADATA +9 -8
  93. {letta_nightly-0.8.17.dev20250722104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/RECORD +96 -68
  94. {letta_nightly-0.8.17.dev20250722104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/LICENSE +0 -0
  95. {letta_nightly-0.8.17.dev20250722104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/WHEEL +0 -0
  96. {letta_nightly-0.8.17.dev20250722104501.dist-info → letta_nightly-0.9.0.dev20250724081419.dist-info}/entry_points.txt +0 -0
@@ -46,6 +46,7 @@ from letta.schemas.block import Block as PydanticBlock
46
46
  from letta.schemas.block import BlockUpdate
47
47
  from letta.schemas.embedding_config import EmbeddingConfig
48
48
  from letta.schemas.enums import ProviderType
49
+ from letta.schemas.file import FileMetadata as PydanticFileMetadata
49
50
  from letta.schemas.group import Group as PydanticGroup
50
51
  from letta.schemas.group import ManagerType
51
52
  from letta.schemas.memory import ContextWindowOverview, Memory
@@ -65,6 +66,7 @@ from letta.server.db import db_registry
65
66
  from letta.services.block_manager import BlockManager
66
67
  from letta.services.context_window_calculator.context_window_calculator import ContextWindowCalculator
67
68
  from letta.services.context_window_calculator.token_counter import AnthropicTokenCounter, TiktokenCounter
69
+ from letta.services.file_processor.chunker.line_chunker import LineChunker
68
70
  from letta.services.files_agents_manager import FileAgentManager
69
71
  from letta.services.helpers.agent_manager_helper import (
70
72
  _apply_filters,
@@ -93,7 +95,7 @@ from letta.services.passage_manager import PassageManager
93
95
  from letta.services.source_manager import SourceManager
94
96
  from letta.services.tool_manager import ToolManager
95
97
  from letta.settings import DatabaseChoice, settings
96
- from letta.utils import enforce_types, united_diff
98
+ from letta.utils import calculate_file_defaults_based_on_context_window, enforce_types, united_diff
97
99
 
98
100
  logger = get_logger(__name__)
99
101
 
@@ -110,32 +112,6 @@ class AgentManager:
110
112
  self.identity_manager = IdentityManager()
111
113
  self.file_agent_manager = FileAgentManager()
112
114
 
113
- @trace_method
114
- async def _validate_agent_exists_async(self, session, agent_id: str, actor: PydanticUser) -> None:
115
- """
116
- Validate that an agent exists and user has access to it using raw SQL for efficiency.
117
-
118
- Args:
119
- session: Database session
120
- agent_id: ID of the agent to validate
121
- actor: User performing the action
122
-
123
- Raises:
124
- NoResultFound: If agent doesn't exist or user doesn't have access
125
- """
126
- agent_check_query = sa.text(
127
- """
128
- SELECT 1 FROM agents
129
- WHERE id = :agent_id
130
- AND organization_id = :org_id
131
- AND is_deleted = false
132
- """
133
- )
134
- agent_exists = await session.execute(agent_check_query, {"agent_id": agent_id, "org_id": actor.organization_id})
135
-
136
- if not agent_exists.fetchone():
137
- raise NoResultFound(f"Agent with ID {agent_id} not found")
138
-
139
115
  @staticmethod
140
116
  def _resolve_tools(session, names: Set[str], ids: Set[str], org_id: str) -> Tuple[Dict[str, str], Dict[str, str]]:
141
117
  """
@@ -385,6 +361,8 @@ class AgentManager:
385
361
  created_by_id=actor.id,
386
362
  last_updated_by_id=actor.id,
387
363
  timezone=agent_create.timezone,
364
+ max_files_open=agent_create.max_files_open,
365
+ per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
388
366
  )
389
367
 
390
368
  if _test_only_force_id:
@@ -574,6 +552,8 @@ class AgentManager:
574
552
  created_by_id=actor.id,
575
553
  last_updated_by_id=actor.id,
576
554
  timezone=agent_create.timezone if agent_create.timezone else DEFAULT_TIMEZONE,
555
+ max_files_open=agent_create.max_files_open,
556
+ per_file_view_window_char_limit=agent_create.per_file_view_window_char_limit,
577
557
  )
578
558
 
579
559
  if _test_only_force_id:
@@ -737,6 +717,9 @@ class AgentManager:
737
717
  "response_format": agent_update.response_format,
738
718
  "last_run_completion": agent_update.last_run_completion,
739
719
  "last_run_duration_ms": agent_update.last_run_duration_ms,
720
+ "max_files_open": agent_update.max_files_open,
721
+ "per_file_view_window_char_limit": agent_update.per_file_view_window_char_limit,
722
+ "timezone": agent_update.timezone,
740
723
  }
741
724
  for col, val in scalar_updates.items():
742
725
  if val is not None:
@@ -860,6 +843,8 @@ class AgentManager:
860
843
  "last_run_completion": agent_update.last_run_completion,
861
844
  "last_run_duration_ms": agent_update.last_run_duration_ms,
862
845
  "timezone": agent_update.timezone,
846
+ "max_files_open": agent_update.max_files_open,
847
+ "per_file_view_window_char_limit": agent_update.per_file_view_window_char_limit,
863
848
  }
864
849
  for col, val in scalar_updates.items():
865
850
  if val is not None:
@@ -1602,6 +1587,7 @@ class AgentManager:
1602
1587
  previous_message_count=num_messages - len(agent_state.message_ids),
1603
1588
  archival_memory_size=num_archival_memories,
1604
1589
  sources=agent_state.sources,
1590
+ max_files_open=agent_state.max_files_open,
1605
1591
  )
1606
1592
 
1607
1593
  diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
@@ -1659,7 +1645,9 @@ class AgentManager:
1659
1645
  # note: we only update the system prompt if the core memory is changed
1660
1646
  # this means that the archival/recall memory statistics may be someout out of date
1661
1647
  curr_memory_str = agent_state.memory.compile(
1662
- sources=agent_state.sources, tool_usage_rules=tool_rules_solver.compile_tool_rule_prompts()
1648
+ sources=agent_state.sources,
1649
+ tool_usage_rules=tool_rules_solver.compile_tool_rule_prompts(),
1650
+ max_files_open=agent_state.max_files_open,
1663
1651
  )
1664
1652
  if curr_memory_str in curr_system_message_openai["content"] and not force:
1665
1653
  # NOTE: could this cause issues if a block is removed? (substring match would still work)
@@ -1687,6 +1675,7 @@ class AgentManager:
1687
1675
  archival_memory_size=num_archival_memories,
1688
1676
  tool_rules_solver=tool_rules_solver,
1689
1677
  sources=agent_state.sources,
1678
+ max_files_open=agent_state.max_files_open,
1690
1679
  )
1691
1680
 
1692
1681
  diff = united_diff(curr_system_message_openai["content"], new_system_message_str)
@@ -1846,7 +1835,11 @@ class AgentManager:
1846
1835
  system_message = await self.message_manager.get_message_by_id_async(message_id=agent_state.message_ids[0], actor=actor)
1847
1836
  temp_tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
1848
1837
  if (
1849
- new_memory.compile(sources=agent_state.sources, tool_usage_rules=temp_tool_rules_solver.compile_tool_rule_prompts())
1838
+ new_memory.compile(
1839
+ sources=agent_state.sources,
1840
+ tool_usage_rules=temp_tool_rules_solver.compile_tool_rule_prompts(),
1841
+ max_files_open=agent_state.max_files_open,
1842
+ )
1850
1843
  not in system_message.content[0].text
1851
1844
  ):
1852
1845
  # update the blocks (LRW) in the DB
@@ -1890,7 +1883,10 @@ class AgentManager:
1890
1883
 
1891
1884
  if file_block_names:
1892
1885
  file_blocks = await self.file_agent_manager.get_all_file_blocks_by_name(
1893
- file_names=file_block_names, agent_id=agent_state.id, actor=actor
1886
+ file_names=file_block_names,
1887
+ agent_id=agent_state.id,
1888
+ actor=actor,
1889
+ per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit,
1894
1890
  )
1895
1891
  agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
1896
1892
 
@@ -1899,7 +1895,12 @@ class AgentManager:
1899
1895
  @enforce_types
1900
1896
  @trace_method
1901
1897
  async def refresh_file_blocks(self, agent_state: PydanticAgentState, actor: PydanticUser) -> PydanticAgentState:
1902
- file_blocks = await self.file_agent_manager.list_files_for_agent(agent_id=agent_state.id, actor=actor, return_as_blocks=True)
1898
+ file_blocks = await self.file_agent_manager.list_files_for_agent(
1899
+ agent_id=agent_state.id,
1900
+ per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit,
1901
+ actor=actor,
1902
+ return_as_blocks=True,
1903
+ )
1903
1904
  agent_state.memory.file_blocks = [b for b in file_blocks if b is not None]
1904
1905
  return agent_state
1905
1906
 
@@ -2593,7 +2594,7 @@ class AgentManager:
2593
2594
 
2594
2595
  @enforce_types
2595
2596
  @trace_method
2596
- async def attach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> PydanticAgentState:
2597
+ async def attach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> None:
2597
2598
  """
2598
2599
  Attaches a tool to an agent.
2599
2600
 
@@ -2610,22 +2611,112 @@ class AgentManager:
2610
2611
  """
2611
2612
  async with db_registry.async_session() as session:
2612
2613
  # Verify the agent exists and user has permission to access it
2613
- agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
2614
+ await validate_agent_exists_async(session, agent_id, actor)
2614
2615
 
2615
- # Use the _process_relationship helper to attach the tool
2616
- await _process_relationship_async(
2617
- session=session,
2618
- agent=agent,
2619
- relationship_name="tools",
2620
- model_class=ToolModel,
2621
- item_ids=[tool_id],
2622
- allow_partial=False, # Ensure the tool exists
2623
- replace=False, # Extend the existing tools
2616
+ # verify tool exists and belongs to organization in a single query with the insert
2617
+ # first, check if tool exists with correct organization
2618
+ tool_check_query = select(func.count(ToolModel.id)).where(
2619
+ ToolModel.id == tool_id, ToolModel.organization_id == actor.organization_id
2624
2620
  )
2621
+ tool_result = await session.execute(tool_check_query)
2622
+ if tool_result.scalar() == 0:
2623
+ raise NoResultFound(f"Tool with id={tool_id} not found in organization={actor.organization_id}")
2624
+
2625
+ # use postgresql on conflict or mysql on duplicate key update for atomic operation
2626
+ if settings.letta_pg_uri_no_default:
2627
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
2628
+
2629
+ insert_stmt = pg_insert(ToolsAgents).values(agent_id=agent_id, tool_id=tool_id)
2630
+ # on conflict do nothing - silently ignore if already exists
2631
+ insert_stmt = insert_stmt.on_conflict_do_nothing(index_elements=["agent_id", "tool_id"])
2632
+ result = await session.execute(insert_stmt)
2633
+ if result.rowcount == 0:
2634
+ logger.info(f"Tool id={tool_id} is already attached to agent id={agent_id}")
2635
+ else:
2636
+ # for sqlite/mysql, check then insert
2637
+ existing_query = (
2638
+ select(func.count()).select_from(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id == tool_id)
2639
+ )
2640
+ existing_result = await session.execute(existing_query)
2641
+ if existing_result.scalar() == 0:
2642
+ insert_stmt = insert(ToolsAgents).values(agent_id=agent_id, tool_id=tool_id)
2643
+ await session.execute(insert_stmt)
2644
+ else:
2645
+ logger.info(f"Tool id={tool_id} is already attached to agent id={agent_id}")
2625
2646
 
2626
- # Commit and refresh the agent
2627
- await agent.update_async(session, actor=actor)
2628
- return await agent.to_pydantic_async()
2647
+ await session.commit()
2648
+
2649
+ @enforce_types
2650
+ @trace_method
2651
+ async def bulk_attach_tools_async(self, agent_id: str, tool_ids: List[str], actor: PydanticUser) -> None:
2652
+ """
2653
+ Efficiently attaches multiple tools to an agent in a single operation.
2654
+
2655
+ Args:
2656
+ agent_id: ID of the agent to attach the tools to.
2657
+ tool_ids: List of tool IDs to attach.
2658
+ actor: User performing the action.
2659
+
2660
+ Raises:
2661
+ NoResultFound: If the agent or any tool is not found.
2662
+ """
2663
+ if not tool_ids:
2664
+ # no tools to attach, nothing to do
2665
+ return
2666
+
2667
+ async with db_registry.async_session() as session:
2668
+ # Verify the agent exists and user has permission to access it
2669
+ await validate_agent_exists_async(session, agent_id, actor)
2670
+
2671
+ # verify all tools exist and belong to organization in a single query
2672
+ tool_check_query = select(func.count(ToolModel.id)).where(
2673
+ ToolModel.id.in_(tool_ids), ToolModel.organization_id == actor.organization_id
2674
+ )
2675
+ tool_result = await session.execute(tool_check_query)
2676
+ found_count = tool_result.scalar()
2677
+
2678
+ if found_count != len(tool_ids):
2679
+ # find which tools are missing for better error message
2680
+ existing_query = select(ToolModel.id).where(ToolModel.id.in_(tool_ids), ToolModel.organization_id == actor.organization_id)
2681
+ existing_result = await session.execute(existing_query)
2682
+ existing_ids = {row[0] for row in existing_result}
2683
+ missing_ids = set(tool_ids) - existing_ids
2684
+ raise NoResultFound(f"Tools with ids={missing_ids} not found in organization={actor.organization_id}")
2685
+
2686
+ if settings.letta_pg_uri_no_default:
2687
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
2688
+
2689
+ # prepare bulk values
2690
+ values = [{"agent_id": agent_id, "tool_id": tool_id} for tool_id in tool_ids]
2691
+
2692
+ # bulk insert with on conflict do nothing
2693
+ insert_stmt = pg_insert(ToolsAgents).values(values)
2694
+ insert_stmt = insert_stmt.on_conflict_do_nothing(index_elements=["agent_id", "tool_id"])
2695
+ result = await session.execute(insert_stmt)
2696
+ logger.info(
2697
+ f"Attached {result.rowcount} new tools to agent {agent_id} (skipped {len(tool_ids) - result.rowcount} already attached)"
2698
+ )
2699
+ else:
2700
+ # for sqlite/mysql, first check which tools are already attached
2701
+ existing_query = select(ToolsAgents.tool_id).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id.in_(tool_ids))
2702
+ existing_result = await session.execute(existing_query)
2703
+ already_attached = {row[0] for row in existing_result}
2704
+
2705
+ # only insert tools that aren't already attached
2706
+ new_tool_ids = [tid for tid in tool_ids if tid not in already_attached]
2707
+
2708
+ if new_tool_ids:
2709
+ # bulk insert new attachments
2710
+ values = [{"agent_id": agent_id, "tool_id": tool_id} for tool_id in new_tool_ids]
2711
+ insert_stmt = insert(ToolsAgents).values(values)
2712
+ await session.execute(insert_stmt)
2713
+ logger.info(
2714
+ f"Attached {len(new_tool_ids)} new tools to agent {agent_id} (skipped {len(already_attached)} already attached)"
2715
+ )
2716
+ else:
2717
+ logger.info(f"All {len(tool_ids)} tools already attached to agent {agent_id}")
2718
+
2719
+ await session.commit()
2629
2720
 
2630
2721
  @enforce_types
2631
2722
  @trace_method
@@ -2634,7 +2725,7 @@ class AgentManager:
2634
2725
  Attaches missing core file tools to an agent.
2635
2726
 
2636
2727
  Args:
2637
- agent_id: ID of the agent to attach the tools to.
2728
+ agent_state: The current agent state with tools already loaded.
2638
2729
  actor: User performing the action.
2639
2730
 
2640
2731
  Raises:
@@ -2643,21 +2734,50 @@ class AgentManager:
2643
2734
  Returns:
2644
2735
  PydanticAgentState: The updated agent state.
2645
2736
  """
2646
- # Check if the agent is missing any files tools
2647
- core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
2648
- missing_tool_names = set(FILES_TOOLS).difference(core_tool_names)
2737
+ # get current file tools attached to the agent
2738
+ attached_file_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
2739
+
2740
+ # determine which file tools are missing
2741
+ missing_tool_names = set(FILES_TOOLS) - attached_file_tool_names
2742
+
2743
+ if not missing_tool_names:
2744
+ # agent already has all file tools
2745
+ return agent_state
2649
2746
 
2650
- for tool_name in missing_tool_names:
2651
- tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor)
2747
+ # get full tool objects for all missing file tools in one query
2748
+ async with db_registry.async_session() as session:
2749
+ query = select(ToolModel).where(
2750
+ ToolModel.name.in_(missing_tool_names),
2751
+ ToolModel.organization_id == actor.organization_id,
2752
+ ToolModel.tool_type == ToolType.LETTA_FILES_CORE,
2753
+ )
2754
+ result = await session.execute(query)
2755
+ found_tool_models = result.scalars().all()
2652
2756
 
2653
- # TODO: This is hacky and deserves a rethink - how do we keep all the base tools available in every org always?
2654
- if not tool_id:
2655
- await self.tool_manager.upsert_base_tools_async(actor=actor, allowed_types={ToolType.LETTA_FILES_CORE})
2757
+ if not found_tool_models:
2758
+ logger.warning(f"No file tools found for organization {actor.organization_id}. Expected tools: {missing_tool_names}")
2759
+ return agent_state
2656
2760
 
2657
- # TODO: Inefficient - I think this re-retrieves the agent_state?
2658
- agent_state = await self.attach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor)
2761
+ # convert to pydantic tools
2762
+ found_tools = [tool.to_pydantic() for tool in found_tool_models]
2763
+ found_tool_names = {tool.name for tool in found_tools}
2659
2764
 
2660
- return agent_state
2765
+ # log if any expected tools weren't found
2766
+ still_missing = missing_tool_names - found_tool_names
2767
+ if still_missing:
2768
+ logger.warning(f"File tools {still_missing} not found in organization {actor.organization_id}")
2769
+
2770
+ # extract tool IDs for bulk attach
2771
+ tool_ids_to_attach = [tool.id for tool in found_tools]
2772
+
2773
+ # bulk attach all found file tools
2774
+ await self.bulk_attach_tools_async(agent_id=agent_state.id, tool_ids=tool_ids_to_attach, actor=actor)
2775
+
2776
+ # create a shallow copy with updated tools list to avoid modifying input
2777
+ agent_state_dict = agent_state.model_dump()
2778
+ agent_state_dict["tools"] = agent_state.tools + found_tools
2779
+
2780
+ return PydanticAgentState(**agent_state_dict)
2661
2781
 
2662
2782
  @enforce_types
2663
2783
  @trace_method
@@ -2666,25 +2786,30 @@ class AgentManager:
2666
2786
  Detach all core file tools from an agent.
2667
2787
 
2668
2788
  Args:
2669
- agent_id: ID of the agent to detach the tools from.
2789
+ agent_state: The current agent state with tools already loaded.
2670
2790
  actor: User performing the action.
2671
2791
 
2672
2792
  Raises:
2673
- NoResultFound: If the agent or tool is not found.
2793
+ NoResultFound: If the agent is not found.
2674
2794
 
2675
2795
  Returns:
2676
2796
  PydanticAgentState: The updated agent state.
2677
2797
  """
2678
- # Check if the agent is missing any files tools
2679
- core_tool_names = {tool.name for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE}
2798
+ # extract file tool IDs directly from agent_state.tools
2799
+ file_tool_ids = [tool.id for tool in agent_state.tools if tool.tool_type == ToolType.LETTA_FILES_CORE]
2680
2800
 
2681
- for tool_name in core_tool_names:
2682
- tool_id = await self.tool_manager.get_tool_id_by_name_async(tool_name=tool_name, actor=actor)
2801
+ if not file_tool_ids:
2802
+ # no file tools to detach
2803
+ return agent_state
2683
2804
 
2684
- # TODO: Inefficient - I think this re-retrieves the agent_state?
2685
- agent_state = await self.detach_tool_async(agent_id=agent_state.id, tool_id=tool_id, actor=actor)
2805
+ # bulk detach all file tools in one operation
2806
+ await self.bulk_detach_tools_async(agent_id=agent_state.id, tool_ids=file_tool_ids, actor=actor)
2686
2807
 
2687
- return agent_state
2808
+ # create a shallow copy with updated tools list to avoid modifying input
2809
+ agent_state_dict = agent_state.model_dump()
2810
+ agent_state_dict["tools"] = [tool for tool in agent_state.tools if tool.tool_type != ToolType.LETTA_FILES_CORE]
2811
+
2812
+ return PydanticAgentState(**agent_state_dict)
2688
2813
 
2689
2814
  @enforce_types
2690
2815
  @trace_method
@@ -2722,7 +2847,7 @@ class AgentManager:
2722
2847
 
2723
2848
  @enforce_types
2724
2849
  @trace_method
2725
- async def detach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> PydanticAgentState:
2850
+ async def detach_tool_async(self, agent_id: str, tool_id: str, actor: PydanticUser) -> None:
2726
2851
  """
2727
2852
  Detaches a tool from an agent.
2728
2853
 
@@ -2732,27 +2857,58 @@ class AgentManager:
2732
2857
  actor: User performing the action.
2733
2858
 
2734
2859
  Raises:
2735
- NoResultFound: If the agent or tool is not found.
2736
-
2737
- Returns:
2738
- PydanticAgentState: The updated agent state.
2860
+ NoResultFound: If the agent is not found.
2739
2861
  """
2740
2862
  async with db_registry.async_session() as session:
2741
2863
  # Verify the agent exists and user has permission to access it
2742
- agent = await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
2864
+ await validate_agent_exists_async(session, agent_id, actor)
2743
2865
 
2744
- # Filter out the tool to be detached
2745
- remaining_tools = [tool for tool in agent.tools if tool.id != tool_id]
2866
+ # Delete the association directly - if it doesn't exist, rowcount will be 0
2867
+ delete_query = delete(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id == tool_id)
2868
+ result = await session.execute(delete_query)
2746
2869
 
2747
- if len(remaining_tools) == len(agent.tools): # Tool ID was not in the relationship
2870
+ if result.rowcount == 0:
2748
2871
  logger.warning(f"Attempted to remove unattached tool id={tool_id} from agent id={agent_id} by actor={actor}")
2872
+ else:
2873
+ logger.debug(f"Detached tool id={tool_id} from agent id={agent_id}")
2749
2874
 
2750
- # Update the tools relationship
2751
- agent.tools = remaining_tools
2875
+ await session.commit()
2752
2876
 
2753
- # Commit and refresh the agent
2754
- await agent.update_async(session, actor=actor)
2755
- return await agent.to_pydantic_async()
2877
+ @enforce_types
2878
+ @trace_method
2879
+ async def bulk_detach_tools_async(self, agent_id: str, tool_ids: List[str], actor: PydanticUser) -> None:
2880
+ """
2881
+ Efficiently detaches multiple tools from an agent in a single operation.
2882
+
2883
+ Args:
2884
+ agent_id: ID of the agent to detach the tools from.
2885
+ tool_ids: List of tool IDs to detach.
2886
+ actor: User performing the action.
2887
+
2888
+ Raises:
2889
+ NoResultFound: If the agent is not found.
2890
+ """
2891
+ if not tool_ids:
2892
+ # no tools to detach, nothing to do
2893
+ return
2894
+
2895
+ async with db_registry.async_session() as session:
2896
+ # Verify the agent exists and user has permission to access it
2897
+ await validate_agent_exists_async(session, agent_id, actor)
2898
+
2899
+ # Delete all associations in a single query
2900
+ delete_query = delete(ToolsAgents).where(ToolsAgents.agent_id == agent_id, ToolsAgents.tool_id.in_(tool_ids))
2901
+ result = await session.execute(delete_query)
2902
+
2903
+ detached_count = result.rowcount
2904
+ if detached_count == 0:
2905
+ logger.warning(f"No tools from list {tool_ids} were attached to agent id={agent_id}")
2906
+ elif detached_count < len(tool_ids):
2907
+ logger.info(f"Detached {detached_count} tools from agent {agent_id} ({len(tool_ids) - detached_count} were not attached)")
2908
+ else:
2909
+ logger.info(f"Detached all {detached_count} tools from agent {agent_id}")
2910
+
2911
+ await session.commit()
2756
2912
 
2757
2913
  @enforce_types
2758
2914
  @trace_method
@@ -2800,6 +2956,83 @@ class AgentManager:
2800
2956
  tools = result.scalars().all()
2801
2957
  return [tool.to_pydantic() for tool in tools]
2802
2958
 
2959
+ # ======================================================================================================================
2960
+ # File Management
2961
+ # ======================================================================================================================
2962
+ async def insert_file_into_context_windows(
2963
+ self,
2964
+ source_id: str,
2965
+ file_metadata_with_content: PydanticFileMetadata,
2966
+ actor: PydanticUser,
2967
+ agent_states: Optional[List[PydanticAgentState]] = None,
2968
+ ) -> List[PydanticAgentState]:
2969
+ """
2970
+ Insert the uploaded document into the context window of all agents
2971
+ attached to the given source.
2972
+ """
2973
+ agent_states = agent_states or await self.source_manager.list_attached_agents(source_id=source_id, actor=actor)
2974
+
2975
+ # Return early
2976
+ if not agent_states:
2977
+ return []
2978
+
2979
+ logger.info(f"Inserting document into context window for source: {source_id}")
2980
+ logger.info(f"Attached agents: {[a.id for a in agent_states]}")
2981
+
2982
+ # Generate visible content for the file
2983
+ line_chunker = LineChunker()
2984
+ content_lines = line_chunker.chunk_text(file_metadata=file_metadata_with_content)
2985
+ visible_content = "\n".join(content_lines)
2986
+ visible_content_map = {file_metadata_with_content.file_name: visible_content}
2987
+
2988
+ # Attach file to each agent using bulk method (one file per agent, but atomic per agent)
2989
+ all_closed_files = await asyncio.gather(
2990
+ *(
2991
+ self.file_agent_manager.attach_files_bulk(
2992
+ agent_id=agent_state.id,
2993
+ files_metadata=[file_metadata_with_content],
2994
+ visible_content_map=visible_content_map,
2995
+ actor=actor,
2996
+ max_files_open=agent_state.max_files_open,
2997
+ )
2998
+ for agent_state in agent_states
2999
+ )
3000
+ )
3001
+ # Flatten and log if any files were closed
3002
+ closed_files = [file for closed_list in all_closed_files for file in closed_list]
3003
+ if closed_files:
3004
+ logger.info(f"LRU eviction closed {len(closed_files)} files during bulk attach: {closed_files}")
3005
+
3006
+ return agent_states
3007
+
3008
+ async def insert_files_into_context_window(
3009
+ self, agent_state: PydanticAgentState, file_metadata_with_content: List[PydanticFileMetadata], actor: PydanticUser
3010
+ ) -> None:
3011
+ """
3012
+ Insert the uploaded documents into the context window of an agent
3013
+ attached to the given source.
3014
+ """
3015
+ logger.info(f"Inserting {len(file_metadata_with_content)} documents into context window for agent_state: {agent_state.id}")
3016
+
3017
+ # Generate visible content for each file
3018
+ line_chunker = LineChunker()
3019
+ visible_content_map = {}
3020
+ for file_metadata in file_metadata_with_content:
3021
+ content_lines = line_chunker.chunk_text(file_metadata=file_metadata)
3022
+ visible_content_map[file_metadata.file_name] = "\n".join(content_lines)
3023
+
3024
+ # Use bulk attach to avoid race conditions and duplicate LRU eviction decisions
3025
+ closed_files = await self.file_agent_manager.attach_files_bulk(
3026
+ agent_id=agent_state.id,
3027
+ files_metadata=file_metadata_with_content,
3028
+ visible_content_map=visible_content_map,
3029
+ actor=actor,
3030
+ max_files_open=agent_state.max_files_open,
3031
+ )
3032
+
3033
+ if closed_files:
3034
+ logger.info(f"LRU eviction closed {len(closed_files)} files during bulk insert: {closed_files}")
3035
+
2803
3036
  # ======================================================================================================================
2804
3037
  # Tag Management
2805
3038
  # ======================================================================================================================
@@ -2888,6 +3121,112 @@ class AgentManager:
2888
3121
  results = [row[0] for row in result.all()]
2889
3122
  return results
2890
3123
 
3124
+ @enforce_types
3125
+ @trace_method
3126
+ async def get_agent_files_config_async(self, agent_id: str, actor: PydanticUser) -> Tuple[int, int]:
3127
+ """Get per_file_view_window_char_limit and max_files_open for an agent.
3128
+
3129
+ This is a performant query that only fetches the specific fields needed.
3130
+
3131
+ Args:
3132
+ agent_id: The ID of the agent
3133
+ actor: The user making the request
3134
+
3135
+ Returns:
3136
+ Tuple of per_file_view_window_char_limit, max_files_open values
3137
+ """
3138
+ async with db_registry.async_session() as session:
3139
+ result = await session.execute(
3140
+ select(AgentModel.per_file_view_window_char_limit, AgentModel.max_files_open)
3141
+ .where(AgentModel.id == agent_id)
3142
+ .where(AgentModel.organization_id == actor.organization_id)
3143
+ .where(AgentModel.is_deleted == False)
3144
+ )
3145
+ row = result.one_or_none()
3146
+
3147
+ if row is None:
3148
+ raise ValueError(f"Agent {agent_id} not found")
3149
+
3150
+ per_file_limit, max_files = row[0], row[1]
3151
+
3152
+ # Handle None values by calculating defaults based on context window
3153
+ if per_file_limit is None or max_files is None:
3154
+ # Get the agent's model context window to calculate appropriate defaults
3155
+ model_result = await session.execute(
3156
+ select(AgentModel.llm_config)
3157
+ .where(AgentModel.id == agent_id)
3158
+ .where(AgentModel.organization_id == actor.organization_id)
3159
+ .where(AgentModel.is_deleted == False)
3160
+ )
3161
+ model_row = model_result.one_or_none()
3162
+ context_window = model_row[0].context_window if model_row and model_row[0] else None
3163
+
3164
+ default_max_files, default_per_file_limit = calculate_file_defaults_based_on_context_window(context_window)
3165
+
3166
+ # Use calculated defaults for None values
3167
+ if per_file_limit is None:
3168
+ per_file_limit = default_per_file_limit
3169
+ if max_files is None:
3170
+ max_files = default_max_files
3171
+
3172
+ return per_file_limit, max_files
3173
+
3174
+ @enforce_types
3175
+ @trace_method
3176
+ async def get_agent_max_files_open_async(self, agent_id: str, actor: PydanticUser) -> int:
3177
+ """Get max_files_open for an agent.
3178
+
3179
+ This is a performant query that only fetches the specific field needed.
3180
+
3181
+ Args:
3182
+ agent_id: The ID of the agent
3183
+ actor: The user making the request
3184
+
3185
+ Returns:
3186
+ max_files_open value
3187
+ """
3188
+ async with db_registry.async_session() as session:
3189
+ result = await session.execute(
3190
+ select(AgentModel.max_files_open)
3191
+ .where(AgentModel.id == agent_id)
3192
+ .where(AgentModel.organization_id == actor.organization_id)
3193
+ .where(AgentModel.is_deleted == False)
3194
+ )
3195
+ row = result.scalar_one_or_none()
3196
+
3197
+ if row is None:
3198
+ raise ValueError(f"Agent {agent_id} not found")
3199
+
3200
+ return row
3201
+
3202
+ @enforce_types
3203
+ @trace_method
3204
+ async def get_agent_per_file_view_window_char_limit_async(self, agent_id: str, actor: PydanticUser) -> int:
3205
+ """Get per_file_view_window_char_limit for an agent.
3206
+
3207
+ This is a performant query that only fetches the specific field needed.
3208
+
3209
+ Args:
3210
+ agent_id: The ID of the agent
3211
+ actor: The user making the request
3212
+
3213
+ Returns:
3214
+ per_file_view_window_char_limit value
3215
+ """
3216
+ async with db_registry.async_session() as session:
3217
+ result = await session.execute(
3218
+ select(AgentModel.per_file_view_window_char_limit)
3219
+ .where(AgentModel.id == agent_id)
3220
+ .where(AgentModel.organization_id == actor.organization_id)
3221
+ .where(AgentModel.is_deleted == False)
3222
+ )
3223
+ row = result.scalar_one_or_none()
3224
+
3225
+ if row is None:
3226
+ raise ValueError(f"Agent {agent_id} not found")
3227
+
3228
+ return row
3229
+
2891
3230
  @trace_method
2892
3231
  async def get_context_window(self, agent_id: str, actor: PydanticUser) -> ContextWindowOverview:
2893
3232
  agent_state, system_message, num_messages, num_archival_memories = await self.rebuild_system_prompt_async(
@@ -2895,7 +3234,7 @@ class AgentManager:
2895
3234
  )
2896
3235
  calculator = ContextWindowCalculator()
2897
3236
 
2898
- if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" and agent_state.llm_config.model_endpoint_type == "anthropic":
3237
+ if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION" or agent_state.llm_config.model_endpoint_type == "anthropic":
2899
3238
  anthropic_client = LLMClient.create(provider_type=ProviderType.anthropic, actor=actor)
2900
3239
  model = agent_state.llm_config.model if agent_state.llm_config.model_endpoint_type == "anthropic" else None
2901
3240