letta-nightly 0.9.1.dev20250731104458__py3-none-any.whl → 0.10.0.dev20250801010504__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 +2 -1
- letta/agent.py +1 -1
- letta/agents/base_agent.py +2 -2
- letta/agents/letta_agent.py +22 -8
- letta/agents/letta_agent_batch.py +2 -2
- letta/agents/voice_agent.py +2 -2
- letta/client/client.py +0 -11
- letta/errors.py +11 -0
- letta/functions/function_sets/builtin.py +3 -7
- letta/functions/mcp_client/types.py +107 -1
- letta/helpers/reasoning_helper.py +48 -0
- letta/helpers/tool_execution_helper.py +2 -65
- letta/interfaces/openai_streaming_interface.py +38 -2
- letta/llm_api/anthropic_client.py +1 -5
- letta/llm_api/google_vertex_client.py +1 -1
- letta/llm_api/llm_client.py +1 -1
- letta/llm_api/openai_client.py +2 -0
- letta/llm_api/sample_response_jsons/lmstudio_embedding_list.json +3 -2
- letta/orm/agent.py +5 -0
- letta/orm/enums.py +0 -1
- letta/orm/file.py +0 -1
- letta/orm/files_agents.py +9 -9
- letta/orm/sandbox_config.py +1 -1
- letta/orm/sqlite_functions.py +15 -13
- letta/prompts/system/memgpt_generate_tool.txt +139 -0
- letta/schemas/agent.py +15 -1
- letta/schemas/enums.py +6 -0
- letta/schemas/file.py +3 -3
- letta/schemas/letta_ping.py +28 -0
- letta/schemas/letta_request.py +9 -0
- letta/schemas/letta_stop_reason.py +25 -0
- letta/schemas/llm_config.py +1 -0
- letta/schemas/mcp.py +16 -3
- letta/schemas/memory.py +5 -0
- letta/schemas/providers/lmstudio.py +7 -0
- letta/schemas/providers/ollama.py +11 -8
- letta/schemas/sandbox_config.py +17 -7
- letta/server/rest_api/app.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +93 -30
- letta/server/rest_api/routers/v1/blocks.py +52 -0
- letta/server/rest_api/routers/v1/sandbox_configs.py +2 -1
- letta/server/rest_api/routers/v1/tools.py +43 -101
- letta/server/rest_api/streaming_response.py +121 -9
- letta/server/server.py +6 -10
- letta/services/agent_manager.py +41 -4
- letta/services/block_manager.py +63 -1
- letta/services/file_processor/chunker/line_chunker.py +20 -19
- letta/services/file_processor/file_processor.py +0 -2
- letta/services/file_processor/file_types.py +1 -2
- letta/services/files_agents_manager.py +46 -6
- letta/services/helpers/agent_manager_helper.py +185 -13
- letta/services/job_manager.py +4 -4
- letta/services/mcp/oauth_utils.py +6 -150
- letta/services/mcp_manager.py +120 -2
- letta/services/sandbox_config_manager.py +3 -5
- letta/services/tool_executor/builtin_tool_executor.py +13 -18
- letta/services/tool_executor/files_tool_executor.py +31 -27
- letta/services/tool_executor/mcp_tool_executor.py +10 -1
- letta/services/tool_executor/{tool_executor.py → sandbox_tool_executor.py} +14 -2
- letta/services/tool_executor/tool_execution_manager.py +1 -1
- letta/services/tool_executor/tool_execution_sandbox.py +2 -1
- letta/services/tool_manager.py +59 -21
- letta/services/tool_sandbox/base.py +18 -2
- letta/services/tool_sandbox/e2b_sandbox.py +5 -35
- letta/services/tool_sandbox/local_sandbox.py +5 -22
- letta/services/tool_sandbox/modal_sandbox.py +205 -0
- letta/settings.py +27 -8
- letta/system.py +1 -4
- letta/templates/template_helper.py +5 -0
- letta/utils.py +14 -2
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/METADATA +7 -3
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/RECORD +75 -72
- letta/orm/__all__.py +0 -15
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/LICENSE +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.dist-info}/WHEEL +0 -0
- {letta_nightly-0.9.1.dev20250731104458.dist-info → letta_nightly-0.10.0.dev20250801010504.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
|
-
#
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
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
|
-
|
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
|
-
|
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,
|
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.
|
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.
|
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.
|
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
|
letta/services/tool_manager.py
CHANGED
@@ -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
|
399
|
-
if
|
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 =
|
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
|
428
|
-
if
|
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 =
|
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
|
192
|
-
|
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.
|
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
|
-
|
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":
|
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=
|
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.
|
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
|
-
|
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
|