letta-nightly 0.11.3.dev20250819104229__py3-none-any.whl → 0.11.4.dev20250820213507__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 (90) hide show
  1. letta/__init__.py +1 -1
  2. letta/agents/helpers.py +4 -0
  3. letta/agents/letta_agent.py +142 -5
  4. letta/constants.py +10 -7
  5. letta/data_sources/connectors.py +70 -53
  6. letta/embeddings.py +3 -240
  7. letta/errors.py +28 -0
  8. letta/functions/function_sets/base.py +4 -4
  9. letta/functions/functions.py +287 -32
  10. letta/functions/mcp_client/types.py +11 -0
  11. letta/functions/schema_validator.py +187 -0
  12. letta/functions/typescript_parser.py +196 -0
  13. letta/helpers/datetime_helpers.py +8 -4
  14. letta/helpers/tool_execution_helper.py +25 -2
  15. letta/llm_api/anthropic_client.py +23 -18
  16. letta/llm_api/azure_client.py +73 -0
  17. letta/llm_api/bedrock_client.py +8 -4
  18. letta/llm_api/google_vertex_client.py +14 -5
  19. letta/llm_api/llm_api_tools.py +2 -217
  20. letta/llm_api/llm_client.py +15 -1
  21. letta/llm_api/llm_client_base.py +32 -1
  22. letta/llm_api/openai.py +1 -0
  23. letta/llm_api/openai_client.py +18 -28
  24. letta/llm_api/together_client.py +55 -0
  25. letta/orm/provider.py +1 -0
  26. letta/orm/step_metrics.py +40 -1
  27. letta/otel/db_pool_monitoring.py +1 -1
  28. letta/schemas/agent.py +3 -4
  29. letta/schemas/agent_file.py +2 -0
  30. letta/schemas/block.py +11 -5
  31. letta/schemas/embedding_config.py +4 -5
  32. letta/schemas/enums.py +1 -1
  33. letta/schemas/job.py +2 -3
  34. letta/schemas/llm_config.py +79 -7
  35. letta/schemas/mcp.py +0 -24
  36. letta/schemas/message.py +0 -108
  37. letta/schemas/openai/chat_completion_request.py +1 -0
  38. letta/schemas/providers/__init__.py +0 -2
  39. letta/schemas/providers/anthropic.py +106 -8
  40. letta/schemas/providers/azure.py +102 -8
  41. letta/schemas/providers/base.py +10 -3
  42. letta/schemas/providers/bedrock.py +28 -16
  43. letta/schemas/providers/letta.py +3 -3
  44. letta/schemas/providers/ollama.py +2 -12
  45. letta/schemas/providers/openai.py +4 -4
  46. letta/schemas/providers/together.py +14 -2
  47. letta/schemas/sandbox_config.py +2 -1
  48. letta/schemas/tool.py +46 -22
  49. letta/server/rest_api/routers/v1/agents.py +179 -38
  50. letta/server/rest_api/routers/v1/folders.py +13 -8
  51. letta/server/rest_api/routers/v1/providers.py +10 -3
  52. letta/server/rest_api/routers/v1/sources.py +14 -8
  53. letta/server/rest_api/routers/v1/steps.py +17 -1
  54. letta/server/rest_api/routers/v1/tools.py +96 -5
  55. letta/server/rest_api/streaming_response.py +91 -45
  56. letta/server/server.py +27 -38
  57. letta/services/agent_manager.py +92 -20
  58. letta/services/agent_serialization_manager.py +11 -7
  59. letta/services/context_window_calculator/context_window_calculator.py +40 -2
  60. letta/services/helpers/agent_manager_helper.py +73 -12
  61. letta/services/mcp_manager.py +109 -15
  62. letta/services/passage_manager.py +28 -109
  63. letta/services/provider_manager.py +24 -0
  64. letta/services/step_manager.py +68 -0
  65. letta/services/summarizer/summarizer.py +1 -4
  66. letta/services/tool_executor/core_tool_executor.py +1 -1
  67. letta/services/tool_executor/sandbox_tool_executor.py +26 -9
  68. letta/services/tool_manager.py +82 -5
  69. letta/services/tool_sandbox/base.py +3 -11
  70. letta/services/tool_sandbox/modal_constants.py +17 -0
  71. letta/services/tool_sandbox/modal_deployment_manager.py +242 -0
  72. letta/services/tool_sandbox/modal_sandbox.py +218 -3
  73. letta/services/tool_sandbox/modal_sandbox_v2.py +429 -0
  74. letta/services/tool_sandbox/modal_version_manager.py +273 -0
  75. letta/services/tool_sandbox/safe_pickle.py +193 -0
  76. letta/settings.py +5 -3
  77. letta/templates/sandbox_code_file.py.j2 +2 -4
  78. letta/templates/sandbox_code_file_async.py.j2 +2 -4
  79. letta/utils.py +1 -1
  80. {letta_nightly-0.11.3.dev20250819104229.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/METADATA +2 -2
  81. {letta_nightly-0.11.3.dev20250819104229.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/RECORD +84 -81
  82. letta/llm_api/anthropic.py +0 -1206
  83. letta/llm_api/aws_bedrock.py +0 -104
  84. letta/llm_api/azure_openai.py +0 -118
  85. letta/llm_api/azure_openai_constants.py +0 -11
  86. letta/llm_api/cohere.py +0 -391
  87. letta/schemas/providers/cohere.py +0 -18
  88. {letta_nightly-0.11.3.dev20250819104229.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/LICENSE +0 -0
  89. {letta_nightly-0.11.3.dev20250819104229.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/WHEEL +0 -0
  90. {letta_nightly-0.11.3.dev20250819104229.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/entry_points.txt +0 -0
@@ -17,6 +17,9 @@ from letta.utils import get_friendly_error_msg
17
17
 
18
18
  logger = get_logger(__name__)
19
19
 
20
+ # class AsyncToolSandboxModalBase(AsyncToolSandboxBase):
21
+ # pass
22
+
20
23
 
21
24
  class AsyncToolSandboxModal(AsyncToolSandboxBase):
22
25
  def __init__(
@@ -30,8 +33,8 @@ class AsyncToolSandboxModal(AsyncToolSandboxBase):
30
33
  ):
31
34
  super().__init__(tool_name, args, user, tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars)
32
35
 
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")
36
+ if not tool_settings.modal_token_id or not tool_settings.modal_token_secret:
37
+ raise ValueError("MODAL_TOKEN_ID and MODAL_TOKEN_SECRET must be set.")
35
38
 
36
39
  # Create a unique app name based on tool and config
37
40
  self._app_name = self._generate_app_name()
@@ -42,7 +45,12 @@ class AsyncToolSandboxModal(AsyncToolSandboxBase):
42
45
 
43
46
  async def _fetch_or_create_modal_app(self, sbx_config: SandboxConfig, env_vars: Dict[str, str]) -> modal.App:
44
47
  """Create a Modal app with the tool function registered."""
45
- app = await modal.App.lookup.aio(self._app_name)
48
+ try:
49
+ app = await modal.App.lookup.aio(self._app_name)
50
+ return app
51
+ except:
52
+ app = modal.App(self._app_name)
53
+
46
54
  modal_config = sbx_config.get_modal_config()
47
55
 
48
56
  # Get the base image with dependencies
@@ -96,6 +104,7 @@ class AsyncToolSandboxModal(AsyncToolSandboxBase):
96
104
 
97
105
  # Execute the tool remotely
98
106
  with app.run():
107
+ # app = modal.Cls.from_name(app.name, "NodeShimServer")()
99
108
  result = app.remote_executor.remote(execution_script, envs)
100
109
 
101
110
  # Process the result
@@ -203,3 +212,209 @@ class AsyncToolSandboxModal(AsyncToolSandboxBase):
203
212
  so we should use asyncio.run() like local execution.
204
213
  """
205
214
  return False
215
+
216
+
217
+ class TypescriptToolSandboxModal(AsyncToolSandboxModal):
218
+ """Modal sandbox implementation for TypeScript tools."""
219
+
220
+ @trace_method
221
+ async def run(
222
+ self,
223
+ agent_state: Optional[AgentState] = None,
224
+ additional_env_vars: Optional[Dict] = None,
225
+ ) -> ToolExecutionResult:
226
+ """Run TypeScript tool in Modal sandbox using Node.js server."""
227
+ if self.provided_sandbox_config:
228
+ sbx_config = self.provided_sandbox_config
229
+ else:
230
+ sbx_config = await self.sandbox_config_manager.get_or_create_default_sandbox_config_async(
231
+ sandbox_type=SandboxType.MODAL, actor=self.user
232
+ )
233
+
234
+ envs = await self._gather_env_vars(agent_state, additional_env_vars or {}, sbx_config.id, is_local=False)
235
+
236
+ # Generate execution script (JSON args for TypeScript)
237
+ json_args = await self.generate_execution_script(agent_state=agent_state)
238
+
239
+ try:
240
+ log_event(
241
+ "modal_typescript_execution_started",
242
+ {"tool": self.tool_name, "app_name": self._app_name, "args": json_args},
243
+ )
244
+
245
+ # Create Modal app with the TypeScript Node.js server
246
+ app = await self._fetch_or_create_modal_app(sbx_config, envs)
247
+
248
+ # Execute the TypeScript tool remotely via the Node.js server
249
+ with app.run():
250
+ # Get the NodeShimServer class from Modal
251
+ node_server = modal.Cls.from_name(self._app_name, "NodeShimServer")
252
+
253
+ # Call the remote_executor method with the JSON arguments
254
+ # The server will parse the JSON and call the TypeScript function
255
+ result = node_server().remote_executor.remote(json_args)
256
+
257
+ # Process the TypeScript execution result
258
+ if isinstance(result, dict) and "error" in result:
259
+ # Handle errors from TypeScript execution
260
+ logger.debug(f"TypeScript tool {self.tool_name} raised an error: {result['error']}")
261
+ func_return = get_friendly_error_msg(
262
+ function_name=self.tool_name,
263
+ exception_name="TypeScriptError",
264
+ exception_message=str(result["error"]),
265
+ )
266
+ log_event(
267
+ "modal_typescript_execution_failed",
268
+ {
269
+ "tool": self.tool_name,
270
+ "app_name": self._app_name,
271
+ "error": result["error"],
272
+ "func_return": func_return,
273
+ },
274
+ )
275
+ return ToolExecutionResult(
276
+ func_return=func_return,
277
+ agent_state=None, # TypeScript tools don't support agent_state yet
278
+ stdout=[],
279
+ stderr=[str(result["error"])],
280
+ status="error",
281
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
282
+ )
283
+ else:
284
+ # Success case - TypeScript function returned a result
285
+ func_return = str(result) if result is not None else ""
286
+ log_event(
287
+ "modal_typescript_execution_succeeded",
288
+ {
289
+ "tool": self.tool_name,
290
+ "app_name": self._app_name,
291
+ "func_return": func_return,
292
+ },
293
+ )
294
+ return ToolExecutionResult(
295
+ func_return=func_return,
296
+ agent_state=None, # TypeScript tools don't support agent_state yet
297
+ stdout=[],
298
+ stderr=[],
299
+ status="success",
300
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
301
+ )
302
+
303
+ except Exception as e:
304
+ logger.error(f"Modal TypeScript execution for tool {self.tool_name} encountered an error: {e}")
305
+ func_return = get_friendly_error_msg(
306
+ function_name=self.tool_name,
307
+ exception_name=type(e).__name__,
308
+ exception_message=str(e),
309
+ )
310
+ log_event(
311
+ "modal_typescript_execution_error",
312
+ {
313
+ "tool": self.tool_name,
314
+ "app_name": self._app_name,
315
+ "error": str(e),
316
+ "func_return": func_return,
317
+ },
318
+ )
319
+ return ToolExecutionResult(
320
+ func_return=func_return,
321
+ agent_state=None,
322
+ stdout=[],
323
+ stderr=[str(e)],
324
+ status="error",
325
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
326
+ )
327
+
328
+ async def _fetch_or_create_modal_app(self, sbx_config: SandboxConfig, env_vars: Dict[str, str]) -> modal.App:
329
+ """Create or fetch a Modal app with TypeScript execution capabilities."""
330
+ try:
331
+ return await modal.App.lookup.aio(self._app_name)
332
+ except:
333
+ app = modal.App(self._app_name)
334
+
335
+ modal_config = sbx_config.get_modal_config()
336
+
337
+ # Get the base image with dependencies
338
+ image = self._get_modal_image(sbx_config)
339
+
340
+ # Import the NodeShimServer that will handle TypeScript execution
341
+ from sandbox.node_server import NodeShimServer
342
+
343
+ # Register the NodeShimServer class with Modal
344
+ # This creates a serverless function that can handle concurrent requests
345
+ app.cls(image=image, restrict_modal_access=True, include_source=False, timeout=modal_config.timeout if modal_config else 60)(
346
+ modal.concurrent(max_inputs=100, target_inputs=50)(NodeShimServer)
347
+ )
348
+
349
+ # Deploy the app to Modal
350
+ with modal.enable_output():
351
+ await app.deploy.aio()
352
+
353
+ return app
354
+
355
+ async def generate_execution_script(self, agent_state: Optional[AgentState], wrap_print_with_markers: bool = False) -> str:
356
+ """Generate the execution script for TypeScript tools.
357
+
358
+ For TypeScript tools, this returns the JSON-encoded arguments that will be passed
359
+ to the Node.js server via the remote_executor method.
360
+ """
361
+ import json
362
+
363
+ # Convert args to JSON string for TypeScript execution
364
+ # The Node.js server expects JSON-encoded arguments
365
+ return json.dumps(self.args)
366
+
367
+ def _get_modal_image(self, sbx_config: SandboxConfig) -> modal.Image:
368
+ """Build a Modal image with Node.js, TypeScript, and the user's tool function."""
369
+ import importlib.util
370
+ from pathlib import Path
371
+
372
+ # Find the sandbox module location
373
+ spec = importlib.util.find_spec("sandbox")
374
+ if not spec or not spec.origin:
375
+ raise ValueError("Could not find sandbox module")
376
+ server_dir = Path(spec.origin).parent
377
+
378
+ # Get the TypeScript function source code
379
+ if not self.tool or not self.tool.source_code:
380
+ raise ValueError("TypeScript tool must have source code")
381
+
382
+ ts_function = self.tool.source_code
383
+
384
+ # Get npm dependencies from sandbox config and tool
385
+ modal_config = sbx_config.get_modal_config()
386
+ npm_dependencies = []
387
+
388
+ # Add dependencies from sandbox config
389
+ if modal_config and modal_config.npm_requirements:
390
+ npm_dependencies.extend(modal_config.npm_requirements)
391
+
392
+ # Add dependencies from the tool itself
393
+ if self.tool.npm_requirements:
394
+ npm_dependencies.extend(self.tool.npm_requirements)
395
+
396
+ # Build npm install command for user dependencies
397
+ user_dependencies_cmd = ""
398
+ if npm_dependencies:
399
+ # Ensure unique dependencies
400
+ unique_deps = list(set(npm_dependencies))
401
+ user_dependencies_cmd = " && npm install " + " ".join(unique_deps)
402
+
403
+ # Escape single quotes in the TypeScript function for shell command
404
+ escaped_ts_function = ts_function.replace("'", "'\\''")
405
+
406
+ # Build the Docker image with Node.js and TypeScript
407
+ image = (
408
+ modal.Image.from_registry("node:22-slim", add_python="3.12")
409
+ .add_local_dir(server_dir, "/root/sandbox", ignore=["node_modules", "build"], copy=True)
410
+ .run_commands(
411
+ # Install dependencies and build the TypeScript server
412
+ f"cd /root/sandbox/resources/server && npm install{user_dependencies_cmd}",
413
+ # Write the user's TypeScript function to a file
414
+ f"echo '{escaped_ts_function}' > /root/sandbox/user-function.ts",
415
+ )
416
+ )
417
+ return image
418
+
419
+
420
+ # probably need to do parse_stdout_best_effort
@@ -0,0 +1,429 @@
1
+ """
2
+ This runs tool calls within an isolated modal sandbox. This does this by doing the following:
3
+ 1. deploying modal functions that embed the original functions
4
+ 2. dynamically executing tools with arguments passed in at runtime
5
+ 3. tracking deployment versions to know when a deployment update is needed
6
+ """
7
+
8
+ from typing import Any, Dict
9
+
10
+ import modal
11
+
12
+ from letta.log import get_logger
13
+ from letta.otel.tracing import log_event, trace_method
14
+ from letta.schemas.agent import AgentState
15
+ from letta.schemas.enums import SandboxType
16
+ from letta.schemas.sandbox_config import SandboxConfig
17
+ from letta.schemas.tool import Tool
18
+ from letta.schemas.tool_execution_result import ToolExecutionResult
19
+ from letta.services.tool_sandbox.base import AsyncToolSandboxBase
20
+ from letta.services.tool_sandbox.modal_constants import DEFAULT_MAX_CONCURRENT_INPUTS, DEFAULT_PYTHON_VERSION
21
+ from letta.services.tool_sandbox.modal_deployment_manager import ModalDeploymentManager
22
+ from letta.services.tool_sandbox.modal_version_manager import ModalVersionManager
23
+ from letta.services.tool_sandbox.safe_pickle import SafePickleError, safe_pickle_dumps, sanitize_for_pickle
24
+ from letta.settings import tool_settings
25
+ from letta.types import JsonDict
26
+ from letta.utils import get_friendly_error_msg
27
+
28
+ logger = get_logger(__name__)
29
+
30
+
31
+ class AsyncToolSandboxModalV2(AsyncToolSandboxBase):
32
+ """Modal sandbox with dynamic argument passing and version tracking."""
33
+
34
+ def __init__(
35
+ self,
36
+ tool_name: str,
37
+ args: JsonDict,
38
+ user,
39
+ tool_object: Tool | None = None,
40
+ sandbox_config: SandboxConfig | None = None,
41
+ sandbox_env_vars: dict[str, Any] | None = None,
42
+ version_manager: ModalVersionManager | None = None,
43
+ use_locking: bool = True,
44
+ use_version_tracking: bool = True,
45
+ ):
46
+ """
47
+ Initialize the Modal sandbox.
48
+
49
+ Args:
50
+ tool_name: Name of the tool to execute
51
+ args: Arguments to pass to the tool
52
+ user: User/actor for permissions
53
+ tool_object: Tool object (optional)
54
+ sandbox_config: Sandbox configuration (optional)
55
+ sandbox_env_vars: Environment variables (optional)
56
+ version_manager: Version manager, will create default if needed (optional)
57
+ use_locking: Whether to use locking for deployment coordination (default: True)
58
+ use_version_tracking: Whether to track and reuse deployments (default: True)
59
+ """
60
+ super().__init__(tool_name, args, user, tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars)
61
+
62
+ if not tool_settings.modal_token_id or not tool_settings.modal_token_secret:
63
+ raise ValueError("MODAL_TOKEN_ID and MODAL_TOKEN_SECRET must be set.")
64
+
65
+ # Initialize deployment manager with configurable options
66
+ self._deployment_manager = ModalDeploymentManager(
67
+ tool=self.tool,
68
+ version_manager=version_manager,
69
+ use_locking=use_locking,
70
+ use_version_tracking=use_version_tracking,
71
+ )
72
+ self._version_hash = None
73
+
74
+ async def _get_or_deploy_modal_app(self, sbx_config: SandboxConfig) -> modal.App:
75
+ """Get existing Modal app or deploy a new version if needed."""
76
+
77
+ app, version_hash = await self._deployment_manager.get_or_deploy_app(
78
+ sbx_config=sbx_config,
79
+ user=self.user,
80
+ create_app_func=self._create_and_deploy_app,
81
+ )
82
+
83
+ self._version_hash = version_hash
84
+ return app
85
+
86
+ async def _create_and_deploy_app(self, sbx_config: SandboxConfig, version: str) -> modal.App:
87
+ """Create and deploy a new Modal app with the executor function."""
88
+ import importlib.util
89
+ from pathlib import Path
90
+
91
+ # App name = tool_id + version hash
92
+ app_full_name = self._deployment_manager.get_full_app_name(version)
93
+ app = modal.App(app_full_name)
94
+
95
+ modal_config = sbx_config.get_modal_config()
96
+ image = self._get_modal_image(sbx_config)
97
+
98
+ # Find the sandbox module dynamically
99
+ spec = importlib.util.find_spec("sandbox")
100
+ if not spec or not spec.origin:
101
+ raise ValueError("Could not find sandbox module")
102
+ sandbox_dir = Path(spec.origin).parent
103
+
104
+ # Read the modal_executor module content
105
+ executor_path = sandbox_dir / "modal_executor.py"
106
+ if not executor_path.exists():
107
+ raise ValueError(f"modal_executor.py not found at {executor_path}")
108
+
109
+ with open(executor_path, "r") as f:
110
+ f.read()
111
+
112
+ # Create a single file mount instead of directory mount
113
+ # This avoids sys.path manipulation
114
+ image = image.add_local_file(str(executor_path), remote_path="/modal_executor.py")
115
+
116
+ # Register the executor function with Modal
117
+ @app.function(
118
+ image=image,
119
+ timeout=modal_config.timeout,
120
+ restrict_modal_access=True,
121
+ max_inputs=DEFAULT_MAX_CONCURRENT_INPUTS,
122
+ serialized=True,
123
+ )
124
+ def tool_executor(
125
+ tool_source: str,
126
+ tool_name: str,
127
+ args_pickled: bytes,
128
+ agent_state_pickled: bytes | None,
129
+ inject_agent_state: bool,
130
+ is_async: bool,
131
+ args_schema_code: str | None,
132
+ environment_vars: Dict[str, Any],
133
+ ) -> Dict[str, Any]:
134
+ """Execute tool in Modal container."""
135
+ # Execute the modal_executor code in a clean namespace
136
+
137
+ # Create a module-like namespace for executor
138
+ executor_namespace = {
139
+ "__name__": "modal_executor",
140
+ "__file__": "/modal_executor.py",
141
+ }
142
+
143
+ # Read and execute the module file
144
+ with open("/modal_executor.py", "r") as f:
145
+ exec(compile(f.read(), "/modal_executor.py", "exec"), executor_namespace)
146
+
147
+ # Call the wrapper function from the executed namespace
148
+ return executor_namespace["execute_tool_wrapper"](
149
+ tool_source=tool_source,
150
+ tool_name=tool_name,
151
+ args_pickled=args_pickled,
152
+ agent_state_pickled=agent_state_pickled,
153
+ inject_agent_state=inject_agent_state,
154
+ is_async=is_async,
155
+ args_schema_code=args_schema_code,
156
+ environment_vars=environment_vars,
157
+ )
158
+
159
+ # Store the function reference
160
+ app.tool_executor = tool_executor
161
+
162
+ # Deploy the app
163
+ logger.info(f"Deploying Modal app {app_full_name}")
164
+ log_event("modal_v2_deploy_started", {"app_name": app_full_name, "version": version})
165
+
166
+ try:
167
+ # Try to look up the app first to see if it already exists
168
+ try:
169
+ await modal.App.lookup.aio(app_full_name)
170
+ logger.info(f"Modal app {app_full_name} already exists, skipping deployment")
171
+ log_event("modal_v2_deploy_already_exists", {"app_name": app_full_name, "version": version})
172
+ # Return the created app with the function attached
173
+ return app
174
+ except:
175
+ # App doesn't exist, need to deploy
176
+ pass
177
+
178
+ with modal.enable_output():
179
+ await app.deploy.aio()
180
+ log_event("modal_v2_deploy_succeeded", {"app_name": app_full_name, "version": version})
181
+ except Exception as e:
182
+ log_event("modal_v2_deploy_failed", {"app_name": app_full_name, "version": version, "error": str(e)})
183
+ raise
184
+
185
+ return app
186
+
187
+ @trace_method
188
+ async def run(
189
+ self,
190
+ agent_state: AgentState | None = None,
191
+ additional_env_vars: Dict | None = None,
192
+ ) -> ToolExecutionResult:
193
+ """Execute the tool in Modal sandbox with dynamic argument passing."""
194
+ if self.provided_sandbox_config:
195
+ sbx_config = self.provided_sandbox_config
196
+ else:
197
+ sbx_config = await self.sandbox_config_manager.get_or_create_default_sandbox_config_async(
198
+ sandbox_type=SandboxType.MODAL, actor=self.user
199
+ )
200
+
201
+ envs = await self._gather_env_vars(agent_state, additional_env_vars or {}, sbx_config.id, is_local=False)
202
+
203
+ # Prepare schema code if needed
204
+ args_schema_code = None
205
+ if self.tool.args_json_schema:
206
+ from letta.services.helpers.tool_execution_helper import add_imports_and_pydantic_schemas_for_args
207
+
208
+ args_schema_code = add_imports_and_pydantic_schemas_for_args(self.tool.args_json_schema)
209
+
210
+ # Serialize arguments and agent state with safety checks
211
+ try:
212
+ args_pickled = safe_pickle_dumps(self.args)
213
+ except SafePickleError as e:
214
+ logger.warning(f"Failed to pickle args, attempting sanitization: {e}")
215
+ sanitized_args = sanitize_for_pickle(self.args)
216
+ try:
217
+ args_pickled = safe_pickle_dumps(sanitized_args)
218
+ except SafePickleError:
219
+ # Final fallback: convert to string representation
220
+ args_pickled = safe_pickle_dumps(str(self.args))
221
+
222
+ agent_state_pickled = None
223
+ if self.inject_agent_state and agent_state:
224
+ try:
225
+ agent_state_pickled = safe_pickle_dumps(agent_state)
226
+ except SafePickleError as e:
227
+ logger.warning(f"Failed to pickle agent state: {e}")
228
+ # For agent state, we prefer to skip injection rather than send corrupted data
229
+ agent_state_pickled = None
230
+ self.inject_agent_state = False
231
+
232
+ try:
233
+ log_event(
234
+ "modal_execution_started",
235
+ {
236
+ "tool": self.tool_name,
237
+ "app_name": self._deployment_manager._app_name,
238
+ "version": self._version_hash,
239
+ "env_vars": list(envs),
240
+ "args_size": len(args_pickled),
241
+ "agent_state_size": len(agent_state_pickled) if agent_state_pickled else 0,
242
+ "inject_agent_state": self.inject_agent_state,
243
+ },
244
+ )
245
+
246
+ # Get or deploy the Modal app
247
+ app = await self._get_or_deploy_modal_app(sbx_config)
248
+
249
+ # Get modal config for timeout settings
250
+ modal_config = sbx_config.get_modal_config()
251
+
252
+ # Execute the tool remotely with retry logic
253
+ max_retries = 3
254
+ retry_delay = 1 # seconds
255
+ last_error = None
256
+
257
+ for attempt in range(max_retries):
258
+ try:
259
+ # Add timeout to prevent hanging
260
+ import asyncio
261
+
262
+ result = await asyncio.wait_for(
263
+ app.tool_executor.remote.aio(
264
+ tool_source=self.tool.source_code,
265
+ tool_name=self.tool.name,
266
+ args_pickled=args_pickled,
267
+ agent_state_pickled=agent_state_pickled,
268
+ inject_agent_state=self.inject_agent_state,
269
+ is_async=self.is_async_function,
270
+ args_schema_code=args_schema_code,
271
+ environment_vars=envs,
272
+ ),
273
+ timeout=modal_config.timeout + 10, # Add 10s buffer to Modal's own timeout
274
+ )
275
+ break # Success, exit retry loop
276
+ except asyncio.TimeoutError as e:
277
+ last_error = e
278
+ logger.warning(f"Modal execution timeout on attempt {attempt + 1}/{max_retries} for tool {self.tool_name}")
279
+ if attempt < max_retries - 1:
280
+ await asyncio.sleep(retry_delay)
281
+ retry_delay *= 2 # Exponential backoff
282
+ except Exception as e:
283
+ last_error = e
284
+ # Check if it's a transient error worth retrying
285
+ error_str = str(e).lower()
286
+ if any(x in error_str for x in ["segmentation fault", "sigsegv", "connection", "timeout"]):
287
+ logger.warning(f"Transient error on attempt {attempt + 1}/{max_retries} for tool {self.tool_name}: {e}")
288
+ if attempt < max_retries - 1:
289
+ await asyncio.sleep(retry_delay)
290
+ retry_delay *= 2
291
+ continue
292
+ # Non-transient error, don't retry
293
+ raise
294
+ else:
295
+ # All retries exhausted
296
+ raise last_error
297
+
298
+ # Process the result
299
+ if result["error"]:
300
+ logger.debug(f"Tool {self.tool_name} raised a {result['error']['name']}: {result['error']['value']}")
301
+ logger.debug(f"Traceback from Modal sandbox: \n{result['error']['traceback']}")
302
+
303
+ # Check for segfault indicators
304
+ is_segfault = False
305
+ if "SIGSEGV" in str(result["error"]["value"]) or "Segmentation fault" in str(result["error"]["value"]):
306
+ is_segfault = True
307
+ logger.error(f"SEGFAULT detected in tool {self.tool_name}: {result['error']['value']}")
308
+
309
+ func_return = get_friendly_error_msg(
310
+ function_name=self.tool_name,
311
+ exception_name=result["error"]["name"],
312
+ exception_message=result["error"]["value"],
313
+ )
314
+ log_event(
315
+ "modal_execution_failed",
316
+ {
317
+ "tool": self.tool_name,
318
+ "app_name": self._deployment_manager._app_name,
319
+ "version": self._version_hash,
320
+ "error_type": result["error"]["name"],
321
+ "error_message": result["error"]["value"],
322
+ "func_return": func_return,
323
+ "is_segfault": is_segfault,
324
+ "stdout": result.get("stdout", ""),
325
+ "stderr": result.get("stderr", ""),
326
+ },
327
+ )
328
+ status = "error"
329
+ else:
330
+ func_return = result["result"]
331
+ agent_state = result["agent_state"]
332
+ log_event(
333
+ "modal_v2_execution_succeeded",
334
+ {
335
+ "tool": self.tool_name,
336
+ "app_name": self._deployment_manager._app_name,
337
+ "version": self._version_hash,
338
+ "func_return": str(func_return)[:500], # Limit logged result size
339
+ "stdout_size": len(result.get("stdout", "")),
340
+ "stderr_size": len(result.get("stderr", "")),
341
+ },
342
+ )
343
+ status = "success"
344
+
345
+ return ToolExecutionResult(
346
+ func_return=func_return,
347
+ agent_state=agent_state if not result["error"] else None,
348
+ stdout=[result["stdout"]] if result["stdout"] else [],
349
+ stderr=[result["stderr"]] if result["stderr"] else [],
350
+ status=status,
351
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
352
+ )
353
+
354
+ except Exception as e:
355
+ import traceback
356
+
357
+ error_context = {
358
+ "tool": self.tool_name,
359
+ "app_name": self._deployment_manager._app_name,
360
+ "version": self._version_hash,
361
+ "error_type": type(e).__name__,
362
+ "error_message": str(e),
363
+ "traceback": traceback.format_exc(),
364
+ }
365
+
366
+ logger.error(f"Modal V2 execution for tool {self.tool_name} encountered an error: {e}", extra=error_context)
367
+
368
+ # Determine if this is a deployment error or execution error
369
+ if "deploy" in str(e).lower() or "modal" in str(e).lower():
370
+ error_category = "deployment_error"
371
+ else:
372
+ error_category = "execution_error"
373
+
374
+ func_return = get_friendly_error_msg(
375
+ function_name=self.tool_name,
376
+ exception_name=type(e).__name__,
377
+ exception_message=str(e),
378
+ )
379
+
380
+ log_event(f"modal_v2_{error_category}", error_context)
381
+
382
+ return ToolExecutionResult(
383
+ func_return=func_return,
384
+ agent_state=None,
385
+ stdout=[],
386
+ stderr=[f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"],
387
+ status="error",
388
+ sandbox_config_fingerprint=sbx_config.fingerprint(),
389
+ )
390
+
391
+ def _get_modal_image(self, sbx_config: SandboxConfig) -> modal.Image:
392
+ """Get Modal image with required public python dependencies.
393
+
394
+ Caching and rebuilding is handled in a cascading manner
395
+ https://modal.com/docs/guide/images#image-caching-and-rebuilds
396
+ """
397
+ # Start with a more robust base image with development tools
398
+ image = modal.Image.debian_slim(python_version=DEFAULT_PYTHON_VERSION)
399
+
400
+ # Add system packages for better C extension support
401
+ image = image.apt_install(
402
+ "build-essential", # Compilation tools
403
+ "libsqlite3-dev", # SQLite development headers
404
+ "libffi-dev", # Foreign Function Interface library
405
+ "libssl-dev", # OpenSSL development headers
406
+ "python3-dev", # Python development headers
407
+ )
408
+
409
+ # Include dependencies required by letta's ORM modules
410
+ # These are needed when unpickling agent_state objects
411
+ all_requirements = [
412
+ "letta",
413
+ "sqlite-vec>=0.1.7a2", # Required for SQLite vector operations
414
+ "numpy<2.0", # Pin numpy to avoid compatibility issues
415
+ ]
416
+
417
+ # Add sandbox-specific pip requirements
418
+ modal_configs = sbx_config.get_modal_config()
419
+ if modal_configs.pip_requirements:
420
+ all_requirements.extend([str(req) for req in modal_configs.pip_requirements])
421
+
422
+ # Add tool-specific pip requirements
423
+ if self.tool and self.tool.pip_requirements:
424
+ all_requirements.extend([str(req) for req in self.tool.pip_requirements])
425
+
426
+ if all_requirements:
427
+ image = image.pip_install(*all_requirements)
428
+
429
+ return image