letta-nightly 0.9.1.dev20250731104458__py3-none-any.whl → 0.10.0.dev20250801060805__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 (77) hide show
  1. letta/__init__.py +2 -1
  2. letta/agent.py +1 -1
  3. letta/agents/base_agent.py +2 -2
  4. letta/agents/letta_agent.py +22 -8
  5. letta/agents/letta_agent_batch.py +2 -2
  6. letta/agents/voice_agent.py +2 -2
  7. letta/client/client.py +0 -11
  8. letta/data_sources/redis_client.py +1 -2
  9. letta/errors.py +11 -0
  10. letta/functions/function_sets/builtin.py +3 -7
  11. letta/functions/mcp_client/types.py +107 -1
  12. letta/helpers/reasoning_helper.py +48 -0
  13. letta/helpers/tool_execution_helper.py +2 -65
  14. letta/interfaces/openai_streaming_interface.py +38 -2
  15. letta/llm_api/anthropic_client.py +1 -5
  16. letta/llm_api/google_vertex_client.py +1 -1
  17. letta/llm_api/llm_client.py +1 -1
  18. letta/llm_api/openai_client.py +2 -0
  19. letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
  20. letta/orm/agent.py +5 -0
  21. letta/orm/enums.py +0 -1
  22. letta/orm/file.py +0 -1
  23. letta/orm/files_agents.py +9 -9
  24. letta/orm/sandbox_config.py +1 -1
  25. letta/orm/sqlite_functions.py +15 -13
  26. letta/prompts/system/memgpt_generate_tool.txt +139 -0
  27. letta/schemas/agent.py +15 -1
  28. letta/schemas/enums.py +6 -0
  29. letta/schemas/file.py +3 -3
  30. letta/schemas/letta_ping.py +28 -0
  31. letta/schemas/letta_request.py +9 -0
  32. letta/schemas/letta_stop_reason.py +25 -0
  33. letta/schemas/llm_config.py +1 -0
  34. letta/schemas/mcp.py +16 -3
  35. letta/schemas/memory.py +5 -0
  36. letta/schemas/providers/lmstudio.py +7 -0
  37. letta/schemas/providers/ollama.py +11 -8
  38. letta/schemas/sandbox_config.py +17 -7
  39. letta/server/rest_api/app.py +2 -0
  40. letta/server/rest_api/routers/v1/agents.py +93 -30
  41. letta/server/rest_api/routers/v1/blocks.py +52 -0
  42. letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
  43. letta/server/rest_api/routers/v1/tools.py +43 -101
  44. letta/server/rest_api/streaming_response.py +121 -9
  45. letta/server/server.py +6 -10
  46. letta/services/agent_manager.py +41 -4
  47. letta/services/block_manager.py +63 -1
  48. letta/services/file_processor/chunker/line_chunker.py +20 -19
  49. letta/services/file_processor/file_processor.py +0 -2
  50. letta/services/file_processor/file_types.py +1 -2
  51. letta/services/files_agents_manager.py +46 -6
  52. letta/services/helpers/agent_manager_helper.py +185 -13
  53. letta/services/job_manager.py +4 -4
  54. letta/services/mcp/oauth_utils.py +6 -150
  55. letta/services/mcp_manager.py +120 -2
  56. letta/services/sandbox_config_manager.py +3 -5
  57. letta/services/tool_executor/builtin_tool_executor.py +13 -18
  58. letta/services/tool_executor/files_tool_executor.py +31 -27
  59. letta/services/tool_executor/mcp_tool_executor.py +10 -1
  60. letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
  61. letta/services/tool_executor/tool_execution_manager.py +1 -1
  62. letta/services/tool_executor/tool_execution_sandbox.py +2 -1
  63. letta/services/tool_manager.py +59 -21
  64. letta/services/tool_sandbox/base.py +18 -2
  65. letta/services/tool_sandbox/e2b_sandbox.py +5 -35
  66. letta/services/tool_sandbox/local_sandbox.py +5 -22
  67. letta/services/tool_sandbox/modal_sandbox.py +205 -0
  68. letta/settings.py +27 -8
  69. letta/system.py +1 -4
  70. letta/templates/template_helper.py +5 -0
  71. letta/utils.py +14 -2
  72. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/METADATA +7 -3
  73. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/RECORD +76 -73
  74. letta/orm/__all__.py +0 -15
  75. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/LICENSE +0 -0
  76. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/WHEEL +0 -0
  77. {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801060805.dist-info}/entry_points.txt +0 -0
@@ -142,6 +142,7 @@ class LettaFileToolExecutor(ToolExecutor):
142
142
  # Process each file
143
143
  opened_files = []
144
144
  all_closed_files = []
145
+ all_previous_ranges = {} # Collect all previous ranges from all files
145
146
 
146
147
  for file_request in file_requests:
147
148
  file_name = file_request.file_name
@@ -181,7 +182,7 @@ class LettaFileToolExecutor(ToolExecutor):
181
182
  visible_content = "\n".join(content_lines)
182
183
 
183
184
  # Handle LRU eviction and file opening
184
- closed_files, was_already_open = await self.files_agents_manager.enforce_max_open_files_and_open(
185
+ closed_files, was_already_open, previous_ranges = await self.files_agents_manager.enforce_max_open_files_and_open(
185
186
  agent_id=agent_state.id,
186
187
  file_id=file_id,
187
188
  file_name=file_name,
@@ -189,42 +190,45 @@ class LettaFileToolExecutor(ToolExecutor):
189
190
  actor=self.actor,
190
191
  visible_content=visible_content,
191
192
  max_files_open=agent_state.max_files_open,
193
+ start_line=start + 1 if start is not None else None, # convert to 1-indexed for user display
194
+ end_line=end if end is not None else None, # end is already exclusive in slicing, so this is correct
192
195
  )
193
196
 
194
197
  opened_files.append(file_name)
195
198
  all_closed_files.extend(closed_files)
199
+ all_previous_ranges.update(previous_ranges) # Merge previous ranges from this file
196
200
 
197
201
  # Update access timestamps for all opened files efficiently
198
202
  await self.files_agents_manager.mark_access_bulk(agent_id=agent_state.id, file_names=file_names, actor=self.actor)
199
203
 
200
- # Build success message
201
- if len(file_requests) == 1:
202
- # Single file - maintain existing format
203
- file_request = file_requests[0]
204
- file_name = file_request.file_name
205
- offset = file_request.offset
206
- length = file_request.length
207
- if offset is not None and length is not None:
208
- end_line = offset + length - 1
209
- success_msg = (
210
- f"Successfully opened file {file_name}, lines {offset} to {end_line} are now visible in memory block <{file_name}>"
211
- )
212
- elif offset is not None:
213
- success_msg = f"Successfully opened file {file_name}, lines {offset} to end are now visible in memory block <{file_name}>"
204
+ # Helper function to format previous range info
205
+ def format_previous_range(file_name: str) -> str:
206
+ if file_name in all_previous_ranges:
207
+ old_start, old_end = all_previous_ranges[file_name]
208
+ if old_start is not None and old_end is not None:
209
+ return f" (previously lines {old_start}-{old_end})"
210
+ elif old_start is not None:
211
+ return f" (previously lines {old_start}-end)"
212
+ else:
213
+ return " (previously full file)"
214
+ return ""
215
+
216
+ # Build unified success message - treat single and multiple files consistently
217
+ file_summaries = []
218
+ for req in file_requests:
219
+ previous_info = format_previous_range(req.file_name)
220
+ if req.offset is not None and req.length is not None:
221
+ end_line = req.offset + req.length - 1
222
+ file_summaries.append(f"{req.file_name} (lines {req.offset}-{end_line}){previous_info}")
223
+ elif req.offset is not None:
224
+ file_summaries.append(f"{req.file_name} (lines {req.offset}-end){previous_info}")
214
225
  else:
215
- success_msg = f"Successfully opened file {file_name}, entire file is now visible in memory block <{file_name}>"
226
+ file_summaries.append(f"{req.file_name}{previous_info}")
227
+
228
+ if len(file_requests) == 1:
229
+ success_msg = f"* Opened {file_summaries[0]}"
216
230
  else:
217
- # Multiple files - show individual ranges if specified
218
- file_summaries = []
219
- for req in file_requests:
220
- if req.offset is not None and req.length is not None:
221
- end_line = req.offset + req.length - 1
222
- file_summaries.append(f"{req.file_name} (lines {req.offset}-{end_line})")
223
- elif req.offset is not None:
224
- file_summaries.append(f"{req.file_name} (lines {req.offset}-end)")
225
- else:
226
- file_summaries.append(req.file_name)
227
- success_msg = f"Successfully opened {len(file_requests)} files: {', '.join(file_summaries)}"
231
+ success_msg = f"* Opened {len(file_requests)} files: {', '.join(file_summaries)}"
228
232
 
229
233
  # Add information about closed files
230
234
  if closed_by_close_all_others:
@@ -35,8 +35,17 @@ class ExternalMCPToolExecutor(ToolExecutor):
35
35
 
36
36
  mcp_manager = MCPManager()
37
37
  # TODO: may need to have better client connection management
38
+
39
+ environment_variables = {}
40
+ if agent_state:
41
+ environment_variables = agent_state.get_agent_env_vars_as_dict()
42
+
38
43
  function_response, success = await mcp_manager.execute_mcp_server_tool(
39
- mcp_server_name=mcp_server_name, tool_name=function_name, tool_args=function_args, actor=actor
44
+ mcp_server_name=mcp_server_name,
45
+ tool_name=function_name,
46
+ tool_args=function_args,
47
+ environment_variables=environment_variables,
48
+ actor=actor,
40
49
  )
41
50
 
42
51
  return ToolExecutionResult(
@@ -5,13 +5,13 @@ from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_fun
5
5
  from letta.log import get_logger
6
6
  from letta.otel.tracing import trace_method
7
7
  from letta.schemas.agent import AgentState
8
+ from letta.schemas.enums import SandboxType
8
9
  from letta.schemas.sandbox_config import SandboxConfig
9
10
  from letta.schemas.tool import Tool
10
11
  from letta.schemas.tool_execution_result import ToolExecutionResult
11
12
  from letta.schemas.user import User
12
13
  from letta.services.agent_manager import AgentManager
13
14
  from letta.services.tool_executor.tool_executor_base import ToolExecutor
14
- from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
15
15
  from letta.services.tool_sandbox.local_sandbox import AsyncToolSandboxLocal
16
16
  from letta.settings import tool_settings
17
17
  from letta.types import JsonDict
@@ -19,6 +19,11 @@ from letta.utils import get_friendly_error_msg
19
19
 
20
20
  logger = get_logger(__name__)
21
21
 
22
+ if tool_settings.e2b_api_key:
23
+ from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
24
+ if tool_settings.modal_api_key:
25
+ from letta.services.tool_sandbox.modal_sandbox import AsyncToolSandboxModal
26
+
22
27
 
23
28
  class SandboxToolExecutor(ToolExecutor):
24
29
  """Executor for sandboxed tools."""
@@ -48,10 +53,14 @@ class SandboxToolExecutor(ToolExecutor):
48
53
  agent_state_copy = self._create_agent_state_copy(agent_state) if agent_state else None
49
54
 
50
55
  # Execute in sandbox depending on API key
51
- if tool_settings.e2b_api_key:
56
+ if tool_settings.sandbox_type == SandboxType.E2B:
52
57
  sandbox = AsyncToolSandboxE2B(
53
58
  function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
54
59
  )
60
+ elif tool_settings.sandbox_type == SandboxType.MODAL:
61
+ sandbox = AsyncToolSandboxModal(
62
+ function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
63
+ )
55
64
  else:
56
65
  sandbox = AsyncToolSandboxLocal(
57
66
  function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
@@ -59,6 +68,9 @@ class SandboxToolExecutor(ToolExecutor):
59
68
 
60
69
  tool_execution_result = await sandbox.run(agent_state=agent_state_copy)
61
70
 
71
+ log_lines = (tool_execution_result.stdout or []) + (tool_execution_result.stderr or [])
72
+ logger.debug("Tool execution log: %s", "\n".join(log_lines))
73
+
62
74
  # Verify memory integrity
63
75
  if agent_state:
64
76
  new_memory_str = await agent_state.memory.compile_async()
@@ -24,7 +24,7 @@ from letta.services.tool_executor.core_tool_executor import LettaCoreToolExecuto
24
24
  from letta.services.tool_executor.files_tool_executor import LettaFileToolExecutor
25
25
  from letta.services.tool_executor.mcp_tool_executor import ExternalMCPToolExecutor
26
26
  from letta.services.tool_executor.multi_agent_tool_executor import LettaMultiAgentToolExecutor
27
- from letta.services.tool_executor.tool_executor import SandboxToolExecutor
27
+ from letta.services.tool_executor.sandbox_tool_executor import SandboxToolExecutor
28
28
  from letta.services.tool_executor.tool_executor_base import ToolExecutor
29
29
  from letta.utils import get_friendly_error_msg
30
30
 
@@ -13,7 +13,8 @@ from letta.functions.helpers import generate_model_from_args_json_schema
13
13
  from letta.log import get_logger
14
14
  from letta.otel.tracing import log_event, trace_method
15
15
  from letta.schemas.agent import AgentState
16
- from letta.schemas.sandbox_config import SandboxConfig, SandboxType
16
+ from letta.schemas.enums import SandboxType
17
+ from letta.schemas.sandbox_config import SandboxConfig
17
18
  from letta.schemas.tool import Tool
18
19
  from letta.schemas.tool_execution_result import ToolExecutionResult
19
20
  from letta.schemas.user import User
@@ -19,6 +19,7 @@ from letta.constants import (
19
19
  LOCAL_ONLY_MULTI_AGENT_TOOLS,
20
20
  MCP_TOOL_TAG_NAME_PREFIX,
21
21
  )
22
+ from letta.errors import LettaToolNameConflictError
22
23
  from letta.functions.functions import derive_openai_json_schema, load_function_set
23
24
  from letta.log import get_logger
24
25
  from letta.orm.enums import ToolType
@@ -143,13 +144,6 @@ class ToolManager:
143
144
  PydanticTool(tool_type=ToolType.EXTERNAL_COMPOSIO, name=tool_create.json_schema["name"], **tool_create.model_dump()), actor
144
145
  )
145
146
 
146
- @enforce_types
147
- @trace_method
148
- def create_or_update_langchain_tool(self, tool_create: ToolCreate, actor: PydanticUser) -> PydanticTool:
149
- return self.create_or_update_tool(
150
- PydanticTool(tool_type=ToolType.EXTERNAL_LANGCHAIN, name=tool_create.json_schema["name"], **tool_create.model_dump()), actor
151
- )
152
-
153
147
  @enforce_types
154
148
  @trace_method
155
149
  def create_tool(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
@@ -306,6 +300,16 @@ class ToolManager:
306
300
  count = result.scalar()
307
301
  return count > 0
308
302
 
303
+ @enforce_types
304
+ @trace_method
305
+ async def tool_name_exists_async(self, tool_name: str, actor: PydanticUser) -> bool:
306
+ """Check if a tool with the given name exists in the user's organization (lightweight check)."""
307
+ async with db_registry.async_session() as session:
308
+ query = select(func.count(ToolModel.id)).where(ToolModel.name == tool_name, ToolModel.organization_id == actor.organization_id)
309
+ result = await session.execute(query)
310
+ count = result.scalar()
311
+ return count > 0
312
+
309
313
  @enforce_types
310
314
  @trace_method
311
315
  async def list_tools_async(
@@ -386,22 +390,39 @@ class ToolManager:
386
390
  self, tool_id: str, tool_update: ToolUpdate, actor: PydanticUser, updated_tool_type: Optional[ToolType] = None
387
391
  ) -> PydanticTool:
388
392
  """Update a tool by its ID with the given ToolUpdate object."""
393
+ # First, check if source code update would cause a name conflict
394
+ update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
395
+ new_name = None
396
+ new_schema = None
397
+
398
+ if "source_code" in update_data.keys() and "json_schema" not in update_data.keys():
399
+ # Derive the new schema and name from the source code
400
+ new_schema = derive_openai_json_schema(source_code=update_data["source_code"])
401
+ new_name = new_schema["name"]
402
+
403
+ # Get current tool to check if name is changing
404
+ current_tool = self.get_tool_by_id(tool_id=tool_id, actor=actor)
405
+
406
+ # Check if the name is changing and if so, verify it doesn't conflict
407
+ if new_name != current_tool.name:
408
+ # Check if a tool with the new name already exists
409
+ existing_tool = self.get_tool_by_name(tool_name=new_name, actor=actor)
410
+ if existing_tool:
411
+ raise LettaToolNameConflictError(tool_name=new_name)
412
+
413
+ # Now perform the update within the session
389
414
  with db_registry.session() as session:
390
415
  # Fetch the tool by ID
391
416
  tool = ToolModel.read(db_session=session, identifier=tool_id, actor=actor)
392
417
 
393
418
  # Update tool attributes with only the fields that were explicitly set
394
- update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
395
419
  for key, value in update_data.items():
396
420
  setattr(tool, key, value)
397
421
 
398
- # If source code is changed and a new json_schema is not provided, we want to auto-refresh the schema
399
- if "source_code" in update_data.keys() and "json_schema" not in update_data.keys():
400
- pydantic_tool = tool.to_pydantic()
401
- new_schema = derive_openai_json_schema(source_code=pydantic_tool.source_code)
402
-
422
+ # If we already computed the new schema, apply it
423
+ if new_schema is not None:
403
424
  tool.json_schema = new_schema
404
- tool.name = new_schema["name"]
425
+ tool.name = new_name
405
426
 
406
427
  if updated_tool_type:
407
428
  tool.tool_type = updated_tool_type
@@ -415,22 +436,39 @@ class ToolManager:
415
436
  self, tool_id: str, tool_update: ToolUpdate, actor: PydanticUser, updated_tool_type: Optional[ToolType] = None
416
437
  ) -> PydanticTool:
417
438
  """Update a tool by its ID with the given ToolUpdate object."""
439
+ # First, check if source code update would cause a name conflict
440
+ update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
441
+ new_name = None
442
+ new_schema = None
443
+
444
+ if "source_code" in update_data.keys() and "json_schema" not in update_data.keys():
445
+ # Derive the new schema and name from the source code
446
+ new_schema = derive_openai_json_schema(source_code=update_data["source_code"])
447
+ new_name = new_schema["name"]
448
+
449
+ # Get current tool to check if name is changing
450
+ current_tool = await self.get_tool_by_id_async(tool_id=tool_id, actor=actor)
451
+
452
+ # Check if the name is changing and if so, verify it doesn't conflict
453
+ if new_name != current_tool.name:
454
+ # Check if a tool with the new name already exists
455
+ name_exists = await self.tool_name_exists_async(tool_name=new_name, actor=actor)
456
+ if name_exists:
457
+ raise LettaToolNameConflictError(tool_name=new_name)
458
+
459
+ # Now perform the update within the session
418
460
  async with db_registry.async_session() as session:
419
461
  # Fetch the tool by ID
420
462
  tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor)
421
463
 
422
464
  # Update tool attributes with only the fields that were explicitly set
423
- update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
424
465
  for key, value in update_data.items():
425
466
  setattr(tool, key, value)
426
467
 
427
- # If source code is changed and a new json_schema is not provided, we want to auto-refresh the schema
428
- if "source_code" in update_data.keys() and "json_schema" not in update_data.keys():
429
- pydantic_tool = tool.to_pydantic()
430
- new_schema = derive_openai_json_schema(source_code=pydantic_tool.source_code)
431
-
468
+ # If we already computed the new schema, apply it
469
+ if new_schema is not None:
432
470
  tool.json_schema = new_schema
433
- tool.name = new_schema["name"]
471
+ tool.name = new_name
434
472
 
435
473
  if updated_tool_type:
436
474
  tool.tool_type = updated_tool_type
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import pickle
2
3
  import uuid
3
4
  from abc import ABC, abstractmethod
@@ -188,5 +189,20 @@ class AsyncToolSandboxBase(ABC):
188
189
  """
189
190
  return False # Default to False for local execution
190
191
 
191
- def _update_env_vars(self):
192
- pass # TODO
192
+ async def _gather_env_vars(self, agent_state: AgentState | None, additional_env_vars: dict[str, str], sbx_id: str, is_local: bool):
193
+ env = os.environ.copy() if is_local else {}
194
+ if self.provided_sandbox_env_vars:
195
+ env.update(self.provided_sandbox_env_vars)
196
+ else:
197
+ env_vars = await self.sandbox_config_manager.get_sandbox_env_vars_as_dict_async(
198
+ sandbox_config_id=sbx_id, actor=self.user, limit=None
199
+ )
200
+ env.update(env_vars)
201
+
202
+ if agent_state:
203
+ env.update(agent_state.get_agent_env_vars_as_dict())
204
+
205
+ if additional_env_vars:
206
+ env.update(additional_env_vars)
207
+
208
+ return env
@@ -6,7 +6,8 @@ from e2b_code_interpreter import AsyncSandbox
6
6
  from letta.log import get_logger
7
7
  from letta.otel.tracing import log_event, trace_method
8
8
  from letta.schemas.agent import AgentState
9
- from letta.schemas.sandbox_config import SandboxConfig, SandboxType
9
+ from letta.schemas.enums import SandboxType
10
+ from letta.schemas.sandbox_config import SandboxConfig
10
11
  from letta.schemas.tool import Tool
11
12
  from letta.schemas.tool_execution_result import ToolExecutionResult
12
13
  from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort
@@ -41,22 +42,6 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
41
42
  self,
42
43
  agent_state: Optional[AgentState] = None,
43
44
  additional_env_vars: Optional[Dict] = None,
44
- ) -> ToolExecutionResult:
45
- """
46
- Run the tool in a sandbox environment asynchronously,
47
- *always* using a subprocess for execution.
48
- """
49
- result = await self.run_e2b_sandbox(agent_state=agent_state, additional_env_vars=additional_env_vars)
50
-
51
- # Simple console logging for demonstration
52
- for log_line in (result.stdout or []) + (result.stderr or []):
53
- print(f"Tool execution log: {log_line}")
54
-
55
- return result
56
-
57
- @trace_method
58
- async def run_e2b_sandbox(
59
- self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None
60
45
  ) -> ToolExecutionResult:
61
46
  if self.provided_sandbox_config:
62
47
  sbx_config = self.provided_sandbox_config
@@ -76,30 +61,15 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
76
61
  # await sbx.set_timeout(sbx_config.get_e2b_config().timeout)
77
62
 
78
63
  # Get environment variables for the sandbox
79
- # TODO: We set limit to 100 here, but maybe we want it uncapped? Realistically this should be fine.
80
- env_vars = {}
81
- if self.provided_sandbox_env_vars:
82
- env_vars.update(self.provided_sandbox_env_vars)
83
- else:
84
- db_env_vars = await self.sandbox_config_manager.get_sandbox_env_vars_as_dict_async(
85
- sandbox_config_id=sbx_config.id, actor=self.user, limit=100
86
- )
87
- env_vars.update(db_env_vars)
88
- # Get environment variables for this agent specifically
89
- if agent_state:
90
- env_vars.update(agent_state.get_agent_env_vars_as_dict())
91
-
92
- # Finally, get any that are passed explicitly into the `run` function call
93
- if additional_env_vars:
94
- env_vars.update(additional_env_vars)
64
+ envs = await self._gather_env_vars(agent_state, additional_env_vars, sbx_config.id, is_local=False)
95
65
  code = await self.generate_execution_script(agent_state=agent_state)
96
66
 
97
67
  try:
98
68
  log_event(
99
69
  "e2b_execution_started",
100
- {"tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "code": code, "env_vars": env_vars},
70
+ {"tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "code": code, "env_vars": envs},
101
71
  )
102
- execution = await e2b_sandbox.run_code(code, envs=env_vars)
72
+ execution = await e2b_sandbox.run_code(code, envs=envs)
103
73
 
104
74
  if execution.results:
105
75
  func_return, agent_state = parse_stdout_best_effort(execution.results[0].text)
@@ -11,7 +11,8 @@ from pydantic.config import JsonDict
11
11
  from letta.log import get_logger
12
12
  from letta.otel.tracing import log_event, trace_method
13
13
  from letta.schemas.agent import AgentState
14
- from letta.schemas.sandbox_config import SandboxConfig, SandboxType
14
+ from letta.schemas.enums import SandboxType
15
+ from letta.schemas.sandbox_config import SandboxConfig
15
16
  from letta.schemas.tool import Tool
16
17
  from letta.schemas.tool_execution_result import ToolExecutionResult
17
18
  from letta.services.helpers.tool_execution_helper import (
@@ -44,34 +45,16 @@ class AsyncToolSandboxLocal(AsyncToolSandboxBase):
44
45
  super().__init__(tool_name, args, user, tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars)
45
46
  self.force_recreate_venv = force_recreate_venv
46
47
 
48
+ @trace_method
47
49
  async def run(
48
50
  self,
49
51
  agent_state: Optional[AgentState] = None,
50
52
  additional_env_vars: Optional[Dict] = None,
51
53
  ) -> ToolExecutionResult:
52
54
  """
53
- Run the tool in a sandbox environment asynchronously,
54
- *always* using a subprocess for execution.
55
- """
56
- result = await self.run_local_dir_sandbox(agent_state=agent_state, additional_env_vars=additional_env_vars)
57
-
58
- # Simple console logging for demonstration
59
- for log_line in (result.stdout or []) + (result.stderr or []):
60
- print(f"Tool execution log: {log_line}")
61
-
62
- return result
63
-
64
- @trace_method
65
- async def run_local_dir_sandbox(
66
- self,
67
- agent_state: Optional[AgentState],
68
- additional_env_vars: Optional[Dict],
69
- ) -> ToolExecutionResult:
70
- """
71
- Unified asynchronous method to run the tool in a local sandbox environment,
72
- always via subprocess for multi-core parallelism.
55
+ Run the tool in a local sandbox environment asynchronously.
56
+ Uses a subprocess for multi-core parallelism.
73
57
  """
74
- # Get sandbox configuration
75
58
  if self.provided_sandbox_config:
76
59
  sbx_config = self.provided_sandbox_config
77
60
  else:
@@ -0,0 +1,205 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ import modal
4
+
5
+ from letta.log import get_logger
6
+ from letta.otel.tracing import log_event, trace_method
7
+ from letta.schemas.agent import AgentState
8
+ from letta.schemas.enums import SandboxType
9
+ from letta.schemas.sandbox_config import SandboxConfig
10
+ from letta.schemas.tool import Tool
11
+ from letta.schemas.tool_execution_result import ToolExecutionResult
12
+ from letta.services.helpers.tool_parser_helper import parse_stdout_best_effort
13
+ from letta.services.tool_sandbox.base import AsyncToolSandboxBase
14
+ from letta.settings import tool_settings
15
+ from letta.types import JsonDict
16
+ from letta.utils import get_friendly_error_msg
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ class AsyncToolSandboxModal(AsyncToolSandboxBase):
22
+ def __init__(
23
+ self,
24
+ tool_name: str,
25
+ args: JsonDict,
26
+ user,
27
+ tool_object: Tool | None = None,
28
+ sandbox_config: SandboxConfig | None = None,
29
+ sandbox_env_vars: dict[str, Any] | None = None,
30
+ ):
31
+ super().__init__(tool_name, args, user, tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars)
32
+
33
+ if not tool_settings.modal_api_key:
34
+ raise ValueError("Modal API key is required but not set in tool_settings.modal_api_key")
35
+
36
+ # Create a unique app name based on tool and config
37
+ self._app_name = self._generate_app_name()
38
+
39
+ def _generate_app_name(self) -> str:
40
+ """Generate a unique app name based on tool and configuration. Created based on tool name and org"""
41
+ return f"{self.user.organization_id}-{self.tool_name}"
42
+
43
+ async def _fetch_or_create_modal_app(self, sbx_config: SandboxConfig, env_vars: Dict[str, str]) -> modal.App:
44
+ """Create a Modal app with the tool function registered."""
45
+ app = await modal.App.lookup.aio(self._app_name)
46
+ modal_config = sbx_config.get_modal_config()
47
+
48
+ # Get the base image with dependencies
49
+ image = self._get_modal_image(sbx_config)
50
+
51
+ # Decorator for the tool, note information on running untrusted code: https://modal.com/docs/guide/restricted-access
52
+ # The `@app.function` decorator must apply to functions in global scope, unless `serialized=True` is set.
53
+ @app.function(image=image, timeout=modal_config.timeout, restrict_modal_access=True, max_inputs=1, serialized=True)
54
+ def execute_tool_with_script(execution_script: str, environment_vars: dict[str, str]):
55
+ """Execute the generated tool script in Modal sandbox."""
56
+ import os
57
+
58
+ # Note: We pass environment variables directly instead of relying on Modal secrets
59
+ # This is more flexible and doesn't require pre-configured secrets
60
+ for key, value in environment_vars.items():
61
+ os.environ[key] = str(value)
62
+
63
+ exec_globals = {}
64
+ exec(execution_script, exec_globals)
65
+
66
+ # Store the function reference in the app for later use
67
+ app.remote_executor = execute_tool_with_script
68
+ return app
69
+
70
+ @trace_method
71
+ async def run(
72
+ self,
73
+ agent_state: Optional[AgentState] = None,
74
+ additional_env_vars: Optional[Dict] = None,
75
+ ) -> ToolExecutionResult:
76
+ if self.provided_sandbox_config:
77
+ sbx_config = self.provided_sandbox_config
78
+ else:
79
+ sbx_config = await self.sandbox_config_manager.get_or_create_default_sandbox_config_async(
80
+ sandbox_type=SandboxType.MODAL, actor=self.user
81
+ )
82
+
83
+ envs = await self._gather_env_vars(agent_state, additional_env_vars or {}, sbx_config.id, is_local=False)
84
+
85
+ # Generate execution script (this includes the tool source code and execution logic)
86
+ execution_script = await self.generate_execution_script(agent_state=agent_state)
87
+
88
+ try:
89
+ log_event(
90
+ "modal_execution_started",
91
+ {"tool": self.tool_name, "app_name": self._app_name, "env_vars": list(envs)},
92
+ )
93
+
94
+ # Create Modal app with the tool function registered
95
+ app = await self._fetch_or_create_modal_app(sbx_config, envs)
96
+
97
+ # Execute the tool remotely
98
+ with app.run():
99
+ result = app.remote_executor.remote(execution_script, envs)
100
+
101
+ # Process the result
102
+ if result["error"]:
103
+ logger.error(
104
+ f"Executing tool {self.tool_name} raised a {result['error']['name']} with message: \n{result['error']['value']}"
105
+ )
106
+ logger.error(f"Traceback from Modal sandbox: \n{result['error']['traceback']}")
107
+ func_return = get_friendly_error_msg(
108
+ function_name=self.tool_name, exception_name=result["error"]["name"], exception_message=result["error"]["value"]
109
+ )
110
+ log_event(
111
+ "modal_execution_failed",
112
+ {
113
+ "tool": self.tool_name,
114
+ "app_name": self._app_name,
115
+ "error_type": result["error"]["name"],
116
+ "error_message": result["error"]["value"],
117
+ "func_return": func_return,
118
+ },
119
+ )
120
+ # Parse the result from stdout even if there was an error
121
+ # (in case the function returned something before failing)
122
+ agent_state = None # Initialize agent_state
123
+ try:
124
+ func_return_parsed, agent_state_parsed = parse_stdout_best_effort(result["stdout"])
125
+ if func_return_parsed is not None:
126
+ func_return = func_return_parsed
127
+ agent_state = agent_state_parsed
128
+ except Exception:
129
+ # If parsing fails, keep the error message
130
+ pass
131
+ else:
132
+ func_return, agent_state = parse_stdout_best_effort(result["stdout"])
133
+ log_event(
134
+ "modal_execution_succeeded",
135
+ {
136
+ "tool": self.tool_name,
137
+ "app_name": self._app_name,
138
+ "func_return": func_return,
139
+ },
140
+ )
141
+
142
+ return ToolExecutionResult(
143
+ func_return=func_return,
144
+ agent_state=agent_state,
145
+ stdout=[result["stdout"]] if result["stdout"] else [],
146
+ stderr=[result["stderr"]] if result["stderr"] else [],
147
+ status="error" if result["error"] else "success",
148
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
149
+ )
150
+
151
+ except Exception as e:
152
+ logger.error(f"Modal execution for tool {self.tool_name} encountered an error: {e}")
153
+ func_return = get_friendly_error_msg(
154
+ function_name=self.tool_name,
155
+ exception_name=type(e).__name__,
156
+ exception_message=str(e),
157
+ )
158
+ log_event(
159
+ "modal_execution_error",
160
+ {
161
+ "tool": self.tool_name,
162
+ "app_name": self._app_name,
163
+ "error": str(e),
164
+ "func_return": func_return,
165
+ },
166
+ )
167
+ return ToolExecutionResult(
168
+ func_return=func_return,
169
+ agent_state=None,
170
+ stdout=[],
171
+ stderr=[str(e)],
172
+ status="error",
173
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
174
+ )
175
+
176
+ def _get_modal_image(self, sbx_config: SandboxConfig) -> modal.Image:
177
+ """Get Modal image with required public python dependencies.
178
+
179
+ Caching and rebuilding is handled in a cascading manner
180
+ https://modal.com/docs/guide/images#image-caching-and-rebuilds
181
+ """
182
+ image = modal.Image.debian_slim(python_version="3.12")
183
+
184
+ all_requirements = ["letta"]
185
+
186
+ # Add sandbox-specific pip requirements
187
+ modal_configs = sbx_config.get_modal_config()
188
+ if modal_configs.pip_requirements:
189
+ all_requirements.extend([str(req) for req in modal_configs.pip_requirements])
190
+
191
+ # Add tool-specific pip requirements
192
+ if self.tool and self.tool.pip_requirements:
193
+ all_requirements.extend([str(req) for req in self.tool.pip_requirements])
194
+
195
+ if all_requirements:
196
+ image = image.pip_install(*all_requirements)
197
+
198
+ return image
199
+
200
+ def use_top_level_await(self) -> bool:
201
+ """
202
+ Modal functions don't have an active event loop by default,
203
+ so we should use asyncio.run() like local execution.
204
+ """
205
+ return False