letta-nightly 0.11.3.dev20250820104219__py3-none-any.whl → 0.11.4.dev20250821104215__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.
- letta/__init__.py +1 -1
- letta/agents/helpers.py +4 -0
- letta/agents/letta_agent.py +142 -5
- letta/constants.py +10 -7
- letta/data_sources/connectors.py +70 -53
- letta/embeddings.py +3 -240
- letta/errors.py +28 -0
- letta/functions/function_sets/base.py +4 -4
- letta/functions/functions.py +287 -32
- letta/functions/mcp_client/types.py +11 -0
- letta/functions/schema_validator.py +187 -0
- letta/functions/typescript_parser.py +196 -0
- letta/helpers/datetime_helpers.py +8 -4
- letta/helpers/tool_execution_helper.py +25 -2
- letta/llm_api/anthropic_client.py +23 -18
- letta/llm_api/azure_client.py +73 -0
- letta/llm_api/bedrock_client.py +8 -4
- letta/llm_api/google_vertex_client.py +14 -5
- letta/llm_api/llm_api_tools.py +2 -217
- letta/llm_api/llm_client.py +15 -1
- letta/llm_api/llm_client_base.py +32 -1
- letta/llm_api/openai.py +1 -0
- letta/llm_api/openai_client.py +18 -28
- letta/llm_api/together_client.py +55 -0
- letta/orm/provider.py +1 -0
- letta/orm/step_metrics.py +40 -1
- letta/otel/db_pool_monitoring.py +1 -1
- letta/schemas/agent.py +3 -4
- letta/schemas/agent_file.py +2 -0
- letta/schemas/block.py +11 -5
- letta/schemas/embedding_config.py +4 -5
- letta/schemas/enums.py +1 -1
- letta/schemas/job.py +2 -3
- letta/schemas/llm_config.py +79 -7
- letta/schemas/mcp.py +0 -24
- letta/schemas/message.py +0 -108
- letta/schemas/openai/chat_completion_request.py +1 -0
- letta/schemas/providers/__init__.py +0 -2
- letta/schemas/providers/anthropic.py +106 -8
- letta/schemas/providers/azure.py +102 -8
- letta/schemas/providers/base.py +10 -3
- letta/schemas/providers/bedrock.py +28 -16
- letta/schemas/providers/letta.py +3 -3
- letta/schemas/providers/ollama.py +2 -12
- letta/schemas/providers/openai.py +4 -4
- letta/schemas/providers/together.py +14 -2
- letta/schemas/sandbox_config.py +2 -1
- letta/schemas/tool.py +46 -22
- letta/server/rest_api/routers/v1/agents.py +179 -38
- letta/server/rest_api/routers/v1/folders.py +13 -8
- letta/server/rest_api/routers/v1/providers.py +10 -3
- letta/server/rest_api/routers/v1/sources.py +14 -8
- letta/server/rest_api/routers/v1/steps.py +17 -1
- letta/server/rest_api/routers/v1/tools.py +96 -5
- letta/server/rest_api/streaming_response.py +91 -45
- letta/server/server.py +27 -38
- letta/services/agent_manager.py +92 -20
- letta/services/agent_serialization_manager.py +11 -7
- letta/services/context_window_calculator/context_window_calculator.py +40 -2
- letta/services/helpers/agent_manager_helper.py +73 -12
- letta/services/mcp_manager.py +109 -15
- letta/services/passage_manager.py +28 -109
- letta/services/provider_manager.py +24 -0
- letta/services/step_manager.py +68 -0
- letta/services/summarizer/summarizer.py +1 -4
- letta/services/tool_executor/core_tool_executor.py +1 -1
- letta/services/tool_executor/sandbox_tool_executor.py +26 -9
- letta/services/tool_manager.py +82 -5
- letta/services/tool_sandbox/base.py +3 -11
- letta/services/tool_sandbox/modal_constants.py +17 -0
- letta/services/tool_sandbox/modal_deployment_manager.py +242 -0
- letta/services/tool_sandbox/modal_sandbox.py +218 -3
- letta/services/tool_sandbox/modal_sandbox_v2.py +429 -0
- letta/services/tool_sandbox/modal_version_manager.py +273 -0
- letta/services/tool_sandbox/safe_pickle.py +193 -0
- letta/settings.py +5 -3
- letta/templates/sandbox_code_file.py.j2 +2 -4
- letta/templates/sandbox_code_file_async.py.j2 +2 -4
- letta/utils.py +1 -1
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/METADATA +2 -2
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/RECORD +84 -81
- letta/llm_api/anthropic.py +0 -1206
- letta/llm_api/aws_bedrock.py +0 -104
- letta/llm_api/azure_openai.py +0 -118
- letta/llm_api/azure_openai_constants.py +0 -11
- letta/llm_api/cohere.py +0 -391
- letta/schemas/providers/cohere.py +0 -18
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/LICENSE +0 -0
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.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.
|
34
|
-
raise ValueError("
|
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
|
-
|
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
|