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
@@ -0,0 +1,95 @@
1
+ import logging
2
+ import os
3
+ import tempfile
4
+
5
+ from markitdown import MarkItDown
6
+ from mistralai import OCRPageObject, OCRResponse, OCRUsageInfo
7
+
8
+ from letta.log import get_logger
9
+ from letta.otel.tracing import trace_method
10
+ from letta.services.file_processor.file_types import is_simple_text_mime_type
11
+ from letta.services.file_processor.parser.base_parser import FileParser
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ # Suppress pdfminer warnings that occur during PDF processing
16
+ logging.getLogger("pdfminer.pdffont").setLevel(logging.ERROR)
17
+ logging.getLogger("pdfminer.pdfinterp").setLevel(logging.ERROR)
18
+ logging.getLogger("pdfminer.pdfpage").setLevel(logging.ERROR)
19
+ logging.getLogger("pdfminer.converter").setLevel(logging.ERROR)
20
+
21
+
22
+ class MarkitdownFileParser(FileParser):
23
+ """Markitdown-based file parsing for documents"""
24
+
25
+ def __init__(self, model: str = "markitdown"):
26
+ self.model = model
27
+
28
+ @trace_method
29
+ async def extract_text(self, content: bytes, mime_type: str) -> OCRResponse:
30
+ """Extract text using markitdown."""
31
+ try:
32
+ # Handle simple text files directly
33
+ if is_simple_text_mime_type(mime_type):
34
+ logger.info(f"Extracting text directly (no processing needed): {self.model}")
35
+ text = content.decode("utf-8", errors="replace")
36
+ return OCRResponse(
37
+ model=self.model,
38
+ pages=[
39
+ OCRPageObject(
40
+ index=0,
41
+ markdown=text,
42
+ images=[],
43
+ dimensions=None,
44
+ )
45
+ ],
46
+ usage_info=OCRUsageInfo(pages_processed=1),
47
+ document_annotation=None,
48
+ )
49
+
50
+ logger.info(f"Extracting text using markitdown: {self.model}")
51
+
52
+ # Create temporary file to pass to markitdown
53
+ with tempfile.NamedTemporaryFile(delete=False, suffix=self._get_file_extension(mime_type)) as temp_file:
54
+ temp_file.write(content)
55
+ temp_file_path = temp_file.name
56
+
57
+ try:
58
+ md = MarkItDown(enable_plugins=False)
59
+ result = md.convert(temp_file_path)
60
+
61
+ return OCRResponse(
62
+ model=self.model,
63
+ pages=[
64
+ OCRPageObject(
65
+ index=0,
66
+ markdown=result.text_content,
67
+ images=[],
68
+ dimensions=None,
69
+ )
70
+ ],
71
+ usage_info=OCRUsageInfo(pages_processed=1),
72
+ document_annotation=None,
73
+ )
74
+ finally:
75
+ # Clean up temporary file
76
+ os.unlink(temp_file_path)
77
+
78
+ except Exception as e:
79
+ logger.error(f"Markitdown text extraction failed: {str(e)}")
80
+ raise
81
+
82
+ def _get_file_extension(self, mime_type: str) -> str:
83
+ """Get file extension based on MIME type for markitdown processing."""
84
+ mime_to_ext = {
85
+ "application/pdf": ".pdf",
86
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
87
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
88
+ "application/vnd.ms-excel": ".xls",
89
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
90
+ "text/csv": ".csv",
91
+ "application/json": ".json",
92
+ "text/xml": ".xml",
93
+ "application/xml": ".xml",
94
+ }
95
+ return mime_to_ext.get(mime_type, ".txt")
@@ -1,14 +1,14 @@
1
1
  from datetime import datetime, timezone
2
- from typing import List, Optional
2
+ from typing import List, Optional, Union
3
3
 
4
- from sqlalchemy import and_, func, select, update
4
+ from sqlalchemy import and_, delete, func, or_, select, update
5
5
 
6
- from letta.constants import MAX_FILES_OPEN
7
6
  from letta.log import get_logger
8
7
  from letta.orm.errors import NoResultFound
9
8
  from letta.orm.files_agents import FileAgent as FileAgentModel
10
9
  from letta.otel.tracing import trace_method
11
10
  from letta.schemas.block import Block as PydanticBlock
11
+ from letta.schemas.block import FileBlock as PydanticFileBlock
12
12
  from letta.schemas.file import FileAgent as PydanticFileAgent
13
13
  from letta.schemas.file import FileMetadata
14
14
  from letta.schemas.user import User as PydanticUser
@@ -31,6 +31,7 @@ class FileAgentManager:
31
31
  file_name: str,
32
32
  source_id: str,
33
33
  actor: PydanticUser,
34
+ max_files_open: int,
34
35
  is_open: bool = True,
35
36
  visible_content: Optional[str] = None,
36
37
  ) -> tuple[PydanticFileAgent, List[str]]:
@@ -40,7 +41,7 @@ class FileAgentManager:
40
41
  • If the row already exists → update `is_open`, `visible_content`
41
42
  and always refresh `last_accessed_at`.
42
43
  • Otherwise create a brand-new association.
43
- • If is_open=True, enforces MAX_FILES_OPEN using LRU eviction.
44
+ • If is_open=True, enforces max_files_open using LRU eviction.
44
45
 
45
46
  Returns:
46
47
  Tuple of (file_agent, closed_file_names)
@@ -54,6 +55,7 @@ class FileAgentManager:
54
55
  source_id=source_id,
55
56
  actor=actor,
56
57
  visible_content=visible_content or "",
58
+ max_files_open=max_files_open,
57
59
  )
58
60
 
59
61
  # Get the updated file agent to return
@@ -160,6 +162,36 @@ class FileAgentManager:
160
162
  assoc = await self._get_association_by_file_id(session, agent_id, file_id, actor)
161
163
  await assoc.hard_delete_async(session, actor=actor)
162
164
 
165
+ @enforce_types
166
+ @trace_method
167
+ async def detach_file_bulk(self, *, agent_file_pairs: List, actor: PydanticUser) -> int: # List of (agent_id, file_id) tuples
168
+ """
169
+ Bulk delete multiple agent-file associations in a single query.
170
+
171
+ Args:
172
+ agent_file_pairs: List of (agent_id, file_id) tuples to delete
173
+ actor: User performing the action
174
+
175
+ Returns:
176
+ Number of rows deleted
177
+ """
178
+ if not agent_file_pairs:
179
+ return 0
180
+
181
+ async with db_registry.async_session() as session:
182
+ # Build compound OR conditions for each agent-file pair
183
+ conditions = []
184
+ for agent_id, file_id in agent_file_pairs:
185
+ conditions.append(and_(FileAgentModel.agent_id == agent_id, FileAgentModel.file_id == file_id))
186
+
187
+ # Create delete statement with all conditions
188
+ stmt = delete(FileAgentModel).where(and_(or_(*conditions), FileAgentModel.organization_id == actor.organization_id))
189
+
190
+ result = await session.execute(stmt)
191
+ await session.commit()
192
+
193
+ return result.rowcount
194
+
163
195
  @enforce_types
164
196
  @trace_method
165
197
  async def get_file_agent_by_id(self, *, agent_id: str, file_id: str, actor: PydanticUser) -> Optional[PydanticFileAgent]:
@@ -177,6 +209,7 @@ class FileAgentManager:
177
209
  *,
178
210
  file_names: List[str],
179
211
  agent_id: str,
212
+ per_file_view_window_char_limit: int,
180
213
  actor: PydanticUser,
181
214
  ) -> List[PydanticBlock]:
182
215
  """
@@ -185,6 +218,7 @@ class FileAgentManager:
185
218
  Args:
186
219
  file_names: List of file names to retrieve
187
220
  agent_id: ID of the agent to retrieve file blocks for
221
+ per_file_view_window_char_limit: The per-file view window char limit
188
222
  actor: The user making the request
189
223
 
190
224
  Returns:
@@ -207,7 +241,7 @@ class FileAgentManager:
207
241
  rows = (await session.execute(query)).scalars().all()
208
242
 
209
243
  # Convert to Pydantic models
210
- return [row.to_pydantic_block() for row in rows]
244
+ return [row.to_pydantic_block(per_file_view_window_char_limit=per_file_view_window_char_limit) for row in rows]
211
245
 
212
246
  @enforce_types
213
247
  @trace_method
@@ -222,8 +256,13 @@ class FileAgentManager:
222
256
  @enforce_types
223
257
  @trace_method
224
258
  async def list_files_for_agent(
225
- self, agent_id: str, actor: PydanticUser, is_open_only: bool = False, return_as_blocks: bool = False
226
- ) -> List[PydanticFileAgent]:
259
+ self,
260
+ agent_id: str,
261
+ per_file_view_window_char_limit: int,
262
+ actor: PydanticUser,
263
+ is_open_only: bool = False,
264
+ return_as_blocks: bool = False,
265
+ ) -> Union[List[PydanticFileAgent], List[PydanticFileBlock]]:
227
266
  """Return associations for *agent_id* (filtering by `is_open` if asked)."""
228
267
  async with db_registry.async_session() as session:
229
268
  conditions = [
@@ -236,7 +275,7 @@ class FileAgentManager:
236
275
  rows = (await session.execute(select(FileAgentModel).where(and_(*conditions)))).scalars().all()
237
276
 
238
277
  if return_as_blocks:
239
- return [r.to_pydantic_block() for r in rows]
278
+ return [r.to_pydantic_block(per_file_view_window_char_limit=per_file_view_window_char_limit) for r in rows]
240
279
  else:
241
280
  return [r.to_pydantic() for r in rows]
242
281
 
@@ -334,7 +373,7 @@ class FileAgentManager:
334
373
  @enforce_types
335
374
  @trace_method
336
375
  async def enforce_max_open_files_and_open(
337
- self, *, agent_id: str, file_id: str, file_name: str, source_id: str, actor: PydanticUser, visible_content: str
376
+ self, *, agent_id: str, file_id: str, file_name: str, source_id: str, actor: PydanticUser, visible_content: str, max_files_open: int
338
377
  ) -> tuple[List[str], bool]:
339
378
  """
340
379
  Efficiently handle LRU eviction and file opening in a single transaction.
@@ -343,7 +382,7 @@ class FileAgentManager:
343
382
  agent_id: ID of the agent
344
383
  file_id: ID of the file to open
345
384
  file_name: Name of the file to open
346
- source_id: ID of the source (denormalized from files.source_id)
385
+ source_id: ID of the source
347
386
  actor: User performing the action
348
387
  visible_content: Content to set for the opened file
349
388
 
@@ -386,7 +425,7 @@ class FileAgentManager:
386
425
 
387
426
  # Calculate how many files need to be closed
388
427
  current_other_count = len(other_open_files)
389
- target_other_count = MAX_FILES_OPEN - 1 # Reserve 1 slot for file we're opening
428
+ target_other_count = max_files_open - 1 # Reserve 1 slot for file we're opening
390
429
 
391
430
  closed_file_names = []
392
431
  if current_other_count > target_other_count:
@@ -443,6 +482,7 @@ class FileAgentManager:
443
482
  *,
444
483
  agent_id: str,
445
484
  files_metadata: list[FileMetadata],
485
+ max_files_open: int,
446
486
  visible_content_map: Optional[dict[str, str]] = None,
447
487
  actor: PydanticUser,
448
488
  ) -> list[str]:
@@ -496,17 +536,17 @@ class FileAgentManager:
496
536
  still_open_names = [r.file_name for r in currently_open if r.file_name not in new_names_set]
497
537
 
498
538
  # decide final open set
499
- if len(new_names) >= MAX_FILES_OPEN:
500
- final_open = new_names[:MAX_FILES_OPEN]
539
+ if len(new_names) >= max_files_open:
540
+ final_open = new_names[:max_files_open]
501
541
  else:
502
- room_for_old = MAX_FILES_OPEN - len(new_names)
542
+ room_for_old = max_files_open - len(new_names)
503
543
  final_open = new_names + still_open_names[-room_for_old:]
504
544
  final_open_set = set(final_open)
505
545
 
506
546
  closed_file_names = [r.file_name for r in currently_open if r.file_name not in final_open_set]
507
- # Add new files that won't be opened due to MAX_FILES_OPEN limit
508
- if len(new_names) >= MAX_FILES_OPEN:
509
- closed_file_names.extend(new_names[MAX_FILES_OPEN:])
547
+ # Add new files that won't be opened due to max_files_open limit
548
+ if len(new_names) >= max_files_open:
549
+ closed_file_names.extend(new_names[max_files_open:])
510
550
  evicted_ids = [r.file_id for r in currently_open if r.file_name in closed_file_names]
511
551
 
512
552
  # upsert requested files
@@ -220,6 +220,13 @@ class GroupManager:
220
220
  group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
221
221
  group.hard_delete(session)
222
222
 
223
+ @enforce_types
224
+ @trace_method
225
+ async def delete_group_async(self, group_id: str, actor: PydanticUser) -> None:
226
+ async with db_registry.async_session() as session:
227
+ group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor)
228
+ await group.hard_delete_async(session)
229
+
223
230
  @enforce_types
224
231
  @trace_method
225
232
  def list_group_messages(
@@ -22,11 +22,12 @@ from letta.constants import (
22
22
  from letta.embeddings import embedding_model
23
23
  from letta.helpers import ToolRulesSolver
24
24
  from letta.helpers.datetime_helpers import format_datetime, get_local_time, get_local_time_fast
25
- from letta.orm import AgentPassage, SourcePassage, SourcesAgents
26
25
  from letta.orm.agent import Agent as AgentModel
27
26
  from letta.orm.agents_tags import AgentsTags
28
27
  from letta.orm.errors import NoResultFound
29
28
  from letta.orm.identity import Identity
29
+ from letta.orm.passage import AgentPassage, SourcePassage
30
+ from letta.orm.sources_agents import SourcesAgents
30
31
  from letta.orm.sqlite_functions import adapt_array
31
32
  from letta.otel.tracing import trace_method
32
33
  from letta.prompts import gpt_system
@@ -45,7 +46,7 @@ from letta.system import get_initial_boot_messages, get_login_event, package_fun
45
46
  # Static methods
46
47
  @trace_method
47
48
  def _process_relationship(
48
- session, agent: AgentModel, relationship_name: str, model_class, item_ids: List[str], allow_partial=False, replace=True
49
+ session, agent: "AgentModel", relationship_name: str, model_class, item_ids: List[str], allow_partial=False, replace=True
49
50
  ):
50
51
  """
51
52
  Generalized function to handle relationships like tools, sources, and blocks using item IDs.
@@ -88,7 +89,7 @@ def _process_relationship(
88
89
 
89
90
  @trace_method
90
91
  async def _process_relationship_async(
91
- session, agent: AgentModel, relationship_name: str, model_class, item_ids: List[str], allow_partial=False, replace=True
92
+ session, agent: "AgentModel", relationship_name: str, model_class, item_ids: List[str], allow_partial=False, replace=True
92
93
  ):
93
94
  """
94
95
  Generalized function to handle relationships like tools, sources, and blocks using item IDs.
@@ -130,7 +131,7 @@ async def _process_relationship_async(
130
131
  current_relationship.extend(new_items)
131
132
 
132
133
 
133
- def _process_tags(agent: AgentModel, tags: List[str], replace=True):
134
+ def _process_tags(agent: "AgentModel", tags: List[str], replace=True):
134
135
  """
135
136
  Handles tags for an agent.
136
137
 
@@ -207,16 +208,21 @@ def compile_memory_metadata_block(
207
208
  timestamp_str = format_datetime(memory_edit_timestamp, timezone)
208
209
 
209
210
  # Create a metadata block of info so the agent knows about the metadata of out-of-context memories
210
- memory_metadata_block = "\n".join(
211
- [
212
- "<memory_metadata>",
213
- f"- The current time is: {get_local_time_fast(timezone)}",
214
- f"- Memory blocks were last modified: {timestamp_str}",
215
- f"- {previous_message_count} previous messages between you and the user are stored in recall memory (use tools to access them)",
216
- f"- {archival_memory_size} total memories you created are stored in archival memory (use tools to access them)",
217
- "</memory_metadata>",
218
- ]
219
- )
211
+ metadata_lines = [
212
+ "<memory_metadata>",
213
+ f"- The current time is: {get_local_time_fast(timezone)}",
214
+ f"- Memory blocks were last modified: {timestamp_str}",
215
+ f"- {previous_message_count} previous messages between you and the user are stored in recall memory (use tools to access them)",
216
+ ]
217
+
218
+ # Only include archival memory line if there are archival memories
219
+ if archival_memory_size > 0:
220
+ metadata_lines.append(
221
+ f"- {archival_memory_size} total memories you created are stored in archival memory (use tools to access them)"
222
+ )
223
+
224
+ metadata_lines.append("</memory_metadata>")
225
+ memory_metadata_block = "\n".join(metadata_lines)
220
226
  return memory_metadata_block
221
227
 
222
228
 
@@ -253,6 +259,7 @@ def compile_system_message(
253
259
  archival_memory_size: int = 0,
254
260
  tool_rules_solver: Optional[ToolRulesSolver] = None,
255
261
  sources: Optional[List] = None,
262
+ max_files_open: Optional[int] = None,
256
263
  ) -> str:
257
264
  """Prepare the final/full system message that will be fed into the LLM API
258
265
 
@@ -285,7 +292,9 @@ def compile_system_message(
285
292
  timezone=timezone,
286
293
  )
287
294
 
288
- memory_with_sources = in_context_memory.compile(tool_usage_rules=tool_constraint_block, sources=sources)
295
+ memory_with_sources = in_context_memory.compile(
296
+ tool_usage_rules=tool_constraint_block, sources=sources, max_files_open=max_files_open
297
+ )
289
298
  full_memory_string = memory_with_sources + "\n\n" + memory_metadata_string
290
299
 
291
300
  # Add to the variables list to inject
@@ -337,6 +346,7 @@ def initialize_message_sequence(
337
346
  previous_message_count=previous_message_count,
338
347
  archival_memory_size=archival_memory_size,
339
348
  sources=agent_state.sources,
349
+ max_files_open=agent_state.max_files_open,
340
350
  )
341
351
  first_user_message = get_login_event(agent_state.timezone) # event letting Letta know the user just logged in
342
352
 
@@ -207,7 +207,7 @@ class ProviderManager:
207
207
 
208
208
  @enforce_types
209
209
  @trace_method
210
- def check_provider_api_key(self, provider_check: ProviderCheck) -> None:
210
+ async def check_provider_api_key(self, provider_check: ProviderCheck) -> None:
211
211
  provider = PydanticProvider(
212
212
  name=provider_check.provider_type.value,
213
213
  provider_type=provider_check.provider_type,
@@ -221,4 +221,4 @@ class ProviderManager:
221
221
  if not provider.api_key:
222
222
  raise ValueError("API key is required")
223
223
 
224
- provider.check_api_key()
224
+ await provider.check_api_key()
@@ -1,5 +1,5 @@
1
1
  import asyncio
2
- from typing import List, Optional
2
+ from typing import List, Optional, Union
3
3
 
4
4
  from sqlalchemy import and_, exists, select
5
5
 
@@ -234,37 +234,56 @@ class SourceManager:
234
234
 
235
235
  @enforce_types
236
236
  @trace_method
237
- async def list_attached_agents(self, source_id: str, actor: PydanticUser) -> List[PydanticAgentState]:
237
+ async def list_attached_agents(
238
+ self, source_id: str, actor: PydanticUser, ids_only: bool = False
239
+ ) -> Union[List[PydanticAgentState], List[str]]:
238
240
  """
239
241
  Lists all agents that have the specified source attached.
240
242
 
241
243
  Args:
242
244
  source_id: ID of the source to find attached agents for
243
245
  actor: User performing the action
246
+ ids_only: If True, return only agent IDs instead of full agent states
244
247
 
245
248
  Returns:
246
- List[PydanticAgentState]: List of agents that have this source attached
249
+ List[PydanticAgentState] | List[str]: List of agents or agent IDs that have this source attached
247
250
  """
248
251
  async with db_registry.async_session() as session:
249
252
  # Verify source exists and user has permission to access it
250
253
  await self._validate_source_exists_async(session, source_id, actor)
251
254
 
252
- # Use junction table query instead of relationship to avoid performance issues
253
- query = (
254
- select(AgentModel)
255
- .join(SourcesAgents, AgentModel.id == SourcesAgents.agent_id)
256
- .where(
257
- SourcesAgents.source_id == source_id,
258
- AgentModel.organization_id == actor.organization_id,
259
- AgentModel.is_deleted == False,
255
+ if ids_only:
256
+ # Query only agent IDs for performance
257
+ query = (
258
+ select(AgentModel.id)
259
+ .join(SourcesAgents, AgentModel.id == SourcesAgents.agent_id)
260
+ .where(
261
+ SourcesAgents.source_id == source_id,
262
+ AgentModel.organization_id == actor.organization_id,
263
+ AgentModel.is_deleted == False,
264
+ )
265
+ .order_by(AgentModel.created_at.desc(), AgentModel.id)
260
266
  )
261
- .order_by(AgentModel.created_at.desc(), AgentModel.id)
262
- )
263
267
 
264
- result = await session.execute(query)
265
- agents_orm = result.scalars().all()
268
+ result = await session.execute(query)
269
+ return list(result.scalars().all())
270
+ else:
271
+ # Use junction table query instead of relationship to avoid performance issues
272
+ query = (
273
+ select(AgentModel)
274
+ .join(SourcesAgents, AgentModel.id == SourcesAgents.agent_id)
275
+ .where(
276
+ SourcesAgents.source_id == source_id,
277
+ AgentModel.organization_id == actor.organization_id,
278
+ AgentModel.is_deleted == False,
279
+ )
280
+ .order_by(AgentModel.created_at.desc(), AgentModel.id)
281
+ )
282
+
283
+ result = await session.execute(query)
284
+ agents_orm = result.scalars().all()
266
285
 
267
- return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents_orm])
286
+ return await asyncio.gather(*[agent.to_pydantic_async() for agent in agents_orm])
268
287
 
269
288
  @enforce_types
270
289
  @trace_method
@@ -2,7 +2,7 @@ import asyncio
2
2
  import re
3
3
  from typing import Any, Dict, List, Optional
4
4
 
5
- from letta.constants import MAX_FILES_OPEN, PINECONE_TEXT_FIELD_NAME
5
+ from letta.constants import PINECONE_TEXT_FIELD_NAME
6
6
  from letta.functions.types import FileOpenRequest
7
7
  from letta.helpers.pinecone_utils import search_pinecone_index, should_use_pinecone
8
8
  from letta.log import get_logger
@@ -117,8 +117,10 @@ class LettaFileToolExecutor(ToolExecutor):
117
117
  file_requests = parsed_requests
118
118
 
119
119
  # Validate file count first
120
- if len(file_requests) > MAX_FILES_OPEN:
121
- raise ValueError(f"Cannot open {len(file_requests)} files: exceeds maximum limit of {MAX_FILES_OPEN} files")
120
+ if len(file_requests) > agent_state.max_files_open:
121
+ raise ValueError(
122
+ f"Cannot open {len(file_requests)} files: exceeds configured maximum limit of {agent_state.max_files_open} files"
123
+ )
122
124
 
123
125
  if not file_requests:
124
126
  raise ValueError("No file requests provided")
@@ -186,6 +188,7 @@ class LettaFileToolExecutor(ToolExecutor):
186
188
  source_id=file.source_id,
187
189
  actor=self.actor,
188
190
  visible_content=visible_content,
191
+ max_files_open=agent_state.max_files_open,
189
192
  )
190
193
 
191
194
  opened_files.append(file_name)
@@ -329,7 +332,9 @@ class LettaFileToolExecutor(ToolExecutor):
329
332
  include_regex = re.compile(include_pattern, re.IGNORECASE)
330
333
 
331
334
  # Get all attached files for this agent
332
- file_agents = await self.files_agents_manager.list_files_for_agent(agent_id=agent_state.id, actor=self.actor)
335
+ file_agents = await self.files_agents_manager.list_files_for_agent(
336
+ agent_id=agent_state.id, per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit, actor=self.actor
337
+ )
333
338
 
334
339
  if not file_agents:
335
340
  return "No files are currently attached to search"
@@ -509,7 +514,9 @@ class LettaFileToolExecutor(ToolExecutor):
509
514
  return f"No valid source IDs found for attached files"
510
515
 
511
516
  # Get all attached files for this agent
512
- file_agents = await self.files_agents_manager.list_files_for_agent(agent_id=agent_state.id, actor=self.actor)
517
+ file_agents = await self.files_agents_manager.list_files_for_agent(
518
+ agent_id=agent_state.id, per_file_view_window_char_limit=agent_state.per_file_view_window_char_limit, actor=self.actor
519
+ )
513
520
  if not file_agents:
514
521
  return "No files are currently attached to search"
515
522
 
@@ -3,6 +3,8 @@ import os
3
3
  import warnings
4
4
  from typing import List, Optional, Set, Union
5
5
 
6
+ from sqlalchemy import func, select
7
+
6
8
  from letta.constants import (
7
9
  BASE_FUNCTION_RETURN_CHAR_LIMIT,
8
10
  BASE_MEMORY_TOOLS,
@@ -290,6 +292,16 @@ class ToolManager:
290
292
  except NoResultFound:
291
293
  return None
292
294
 
295
+ @enforce_types
296
+ @trace_method
297
+ async def tool_exists_async(self, tool_id: str, actor: PydanticUser) -> bool:
298
+ """Check if a tool exists and belongs to the user's organization (lightweight check)."""
299
+ async with db_registry.async_session() as session:
300
+ query = select(func.count(ToolModel.id)).where(ToolModel.id == tool_id, ToolModel.organization_id == actor.organization_id)
301
+ result = await session.execute(query)
302
+ count = result.scalar()
303
+ return count > 0
304
+
293
305
  @enforce_types
294
306
  @trace_method
295
307
  async def list_tools_async(