letta-nightly 0.11.3.dev20250820104219__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.
- 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.dev20250820213507.dist-info}/METADATA +2 -2
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250820213507.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.dev20250820213507.dist-info}/LICENSE +0 -0
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250820213507.dist-info}/entry_points.txt +0 -0
letta/services/step_manager.py
CHANGED
@@ -11,11 +11,13 @@ from letta.orm.errors import NoResultFound
|
|
11
11
|
from letta.orm.job import Job as JobModel
|
12
12
|
from letta.orm.sqlalchemy_base import AccessType
|
13
13
|
from letta.orm.step import Step as StepModel
|
14
|
+
from letta.orm.step_metrics import StepMetrics as StepMetricsModel
|
14
15
|
from letta.otel.tracing import get_trace_id, trace_method
|
15
16
|
from letta.schemas.enums import StepStatus
|
16
17
|
from letta.schemas.letta_stop_reason import LettaStopReason, StopReasonType
|
17
18
|
from letta.schemas.openai.chat_completion_response import UsageStatistics
|
18
19
|
from letta.schemas.step import Step as PydanticStep
|
20
|
+
from letta.schemas.step_metrics import StepMetrics as PydanticStepMetrics
|
19
21
|
from letta.schemas.user import User as PydanticUser
|
20
22
|
from letta.server.db import db_registry
|
21
23
|
from letta.utils import enforce_types
|
@@ -187,6 +189,13 @@ class StepManager:
|
|
187
189
|
step = await StepModel.read_async(db_session=session, identifier=step_id, actor=actor)
|
188
190
|
return step.to_pydantic()
|
189
191
|
|
192
|
+
@enforce_types
|
193
|
+
@trace_method
|
194
|
+
async def get_step_metrics_async(self, step_id: str, actor: PydanticUser) -> PydanticStepMetrics:
|
195
|
+
async with db_registry.async_session() as session:
|
196
|
+
metrics = await StepMetricsModel.read_async(db_session=session, identifier=step_id, actor=actor)
|
197
|
+
return metrics.to_pydantic()
|
198
|
+
|
190
199
|
@enforce_types
|
191
200
|
@trace_method
|
192
201
|
async def add_feedback_async(self, step_id: str, feedback: Optional[FeedbackType], actor: PydanticUser) -> PydanticStep:
|
@@ -372,6 +381,65 @@ class StepManager:
|
|
372
381
|
await session.commit()
|
373
382
|
return step.to_pydantic()
|
374
383
|
|
384
|
+
@enforce_types
|
385
|
+
@trace_method
|
386
|
+
async def record_step_metrics_async(
|
387
|
+
self,
|
388
|
+
actor: PydanticUser,
|
389
|
+
step_id: str,
|
390
|
+
llm_request_ns: Optional[int] = None,
|
391
|
+
tool_execution_ns: Optional[int] = None,
|
392
|
+
step_ns: Optional[int] = None,
|
393
|
+
agent_id: Optional[str] = None,
|
394
|
+
job_id: Optional[str] = None,
|
395
|
+
project_id: Optional[str] = None,
|
396
|
+
template_id: Optional[str] = None,
|
397
|
+
base_template_id: Optional[str] = None,
|
398
|
+
) -> PydanticStepMetrics:
|
399
|
+
"""Record performance metrics for a step.
|
400
|
+
|
401
|
+
Args:
|
402
|
+
actor: The user making the request
|
403
|
+
step_id: The ID of the step to record metrics for
|
404
|
+
llm_request_ns: Time spent on LLM request in nanoseconds
|
405
|
+
tool_execution_ns: Time spent on tool execution in nanoseconds
|
406
|
+
step_ns: Total time for the step in nanoseconds
|
407
|
+
agent_id: The ID of the agent
|
408
|
+
job_id: The ID of the job
|
409
|
+
project_id: The ID of the project
|
410
|
+
template_id: The ID of the template
|
411
|
+
base_template_id: The ID of the base template
|
412
|
+
|
413
|
+
Returns:
|
414
|
+
The created step metrics
|
415
|
+
|
416
|
+
Raises:
|
417
|
+
NoResultFound: If the step does not exist
|
418
|
+
"""
|
419
|
+
async with db_registry.async_session() as session:
|
420
|
+
step = await session.get(StepModel, step_id)
|
421
|
+
if not step:
|
422
|
+
raise NoResultFound(f"Step with id {step_id} does not exist")
|
423
|
+
if step.organization_id != actor.organization_id:
|
424
|
+
raise Exception("Unauthorized")
|
425
|
+
|
426
|
+
metrics_data = {
|
427
|
+
"id": step_id,
|
428
|
+
"organization_id": actor.organization_id,
|
429
|
+
"agent_id": agent_id or step.agent_id,
|
430
|
+
"job_id": job_id or step.job_id,
|
431
|
+
"project_id": project_id or step.project_id,
|
432
|
+
"llm_request_ns": llm_request_ns,
|
433
|
+
"tool_execution_ns": tool_execution_ns,
|
434
|
+
"step_ns": step_ns,
|
435
|
+
"template_id": template_id,
|
436
|
+
"base_template_id": base_template_id,
|
437
|
+
}
|
438
|
+
|
439
|
+
metrics = StepMetricsModel(**metrics_data)
|
440
|
+
await metrics.create_async(session)
|
441
|
+
return metrics.to_pydantic()
|
442
|
+
|
375
443
|
def _verify_job_access(
|
376
444
|
self,
|
377
445
|
session: Session,
|
@@ -348,12 +348,9 @@ async def simple_summary(messages: List[Message], llm_config: LLMConfig, actor:
|
|
348
348
|
{"role": "system", "content": system_prompt},
|
349
349
|
{"role": "user", "content": summary_transcript},
|
350
350
|
]
|
351
|
-
print("messages going to summarizer:", input_messages)
|
352
351
|
input_messages_obj = [simple_message_wrapper(msg) for msg in input_messages]
|
353
|
-
print("messages going to summarizer (objs):", input_messages_obj)
|
354
|
-
|
355
352
|
request_data = llm_client.build_request_data(input_messages_obj, llm_config, tools=[])
|
356
|
-
|
353
|
+
|
357
354
|
# NOTE: we should disable the inner_thoughts_in_kwargs here, because we don't use it
|
358
355
|
# I'm leaving it commented it out for now for safety but is fine assuming the var here is a copy not a reference
|
359
356
|
# llm_config.put_inner_thoughts_in_kwargs = False
|
@@ -174,7 +174,7 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
174
174
|
Returns:
|
175
175
|
Optional[str]: None is always returned as this function does not produce a response.
|
176
176
|
"""
|
177
|
-
await PassageManager().
|
177
|
+
await PassageManager().insert_passage(
|
178
178
|
agent_state=agent_state,
|
179
179
|
text=content,
|
180
180
|
actor=actor,
|
@@ -5,7 +5,7 @@ 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
|
+
from letta.schemas.enums import SandboxType, ToolSourceType
|
9
9
|
from letta.schemas.sandbox_config import SandboxConfig
|
10
10
|
from letta.schemas.tool import Tool
|
11
11
|
from letta.schemas.tool_execution_result import ToolExecutionResult
|
@@ -19,11 +19,6 @@ 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
|
-
|
27
22
|
|
28
23
|
class SandboxToolExecutor(ToolExecutor):
|
29
24
|
"""Executor for sandboxed tools."""
|
@@ -54,13 +49,35 @@ class SandboxToolExecutor(ToolExecutor):
|
|
54
49
|
|
55
50
|
# Execute in sandbox depending on API key
|
56
51
|
if tool_settings.sandbox_type == SandboxType.E2B:
|
52
|
+
from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
|
53
|
+
|
57
54
|
sandbox = AsyncToolSandboxE2B(
|
58
55
|
function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
|
59
56
|
)
|
57
|
+
# TODO (cliandy): this is just for testing right now, separate this out into it's own subclass and handling logic
|
60
58
|
elif tool_settings.sandbox_type == SandboxType.MODAL:
|
61
|
-
|
62
|
-
|
63
|
-
|
59
|
+
from letta.services.tool_sandbox.modal_sandbox import AsyncToolSandboxModal, TypescriptToolSandboxModal
|
60
|
+
|
61
|
+
if tool.source_type == ToolSourceType.typescript:
|
62
|
+
sandbox = TypescriptToolSandboxModal(
|
63
|
+
function_name,
|
64
|
+
function_args,
|
65
|
+
actor,
|
66
|
+
tool_object=tool,
|
67
|
+
sandbox_config=sandbox_config,
|
68
|
+
sandbox_env_vars=sandbox_env_vars,
|
69
|
+
)
|
70
|
+
elif tool.source_type == ToolSourceType.python:
|
71
|
+
sandbox = AsyncToolSandboxModal(
|
72
|
+
function_name,
|
73
|
+
function_args,
|
74
|
+
actor,
|
75
|
+
tool_object=tool,
|
76
|
+
sandbox_config=sandbox_config,
|
77
|
+
sandbox_env_vars=sandbox_env_vars,
|
78
|
+
)
|
79
|
+
else:
|
80
|
+
raise ValueError(f"Tool source type was {tool.source_type} but is required to be python or typescript to run in Modal.")
|
64
81
|
else:
|
65
82
|
sandbox = AsyncToolSandboxLocal(
|
66
83
|
function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
|
letta/services/tool_manager.py
CHANGED
@@ -404,7 +404,37 @@ class ToolManager:
|
|
404
404
|
updated_tool_type: Optional[ToolType] = None,
|
405
405
|
bypass_name_check: bool = False,
|
406
406
|
) -> PydanticTool:
|
407
|
-
"""
|
407
|
+
"""
|
408
|
+
Update a tool with complex validation and schema derivation logic.
|
409
|
+
|
410
|
+
This method handles updates differently based on tool type:
|
411
|
+
- MCP tools: JSON schema is trusted, no Python source derivation
|
412
|
+
- Python/TypeScript tools: Schema derived from source code if provided
|
413
|
+
- Name conflicts are checked unless bypassed
|
414
|
+
|
415
|
+
Args:
|
416
|
+
tool_id: The UUID of the tool to update
|
417
|
+
tool_update: Partial update data (only changed fields)
|
418
|
+
actor: User performing the update (for permissions)
|
419
|
+
updated_tool_type: Optional new tool type (e.g., converting custom to builtin)
|
420
|
+
bypass_name_check: Skip name conflict validation (use with caution)
|
421
|
+
|
422
|
+
Returns:
|
423
|
+
Updated tool as Pydantic model
|
424
|
+
|
425
|
+
Raises:
|
426
|
+
LettaToolNameConflictError: If new name conflicts with existing tool
|
427
|
+
NoResultFound: If tool doesn't exist or user lacks access
|
428
|
+
|
429
|
+
Side Effects:
|
430
|
+
- Updates tool in database
|
431
|
+
- May change tool name if source code is modified
|
432
|
+
- Recomputes JSON schema from source for non-MCP tools
|
433
|
+
|
434
|
+
Important:
|
435
|
+
When source_code is provided for Python/TypeScript tools, the name
|
436
|
+
MUST match the function name in the code, overriding any name in json_schema
|
437
|
+
"""
|
408
438
|
# First, check if source code update would cause a name conflict
|
409
439
|
update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
|
410
440
|
new_name = None
|
@@ -429,7 +459,16 @@ class ToolManager:
|
|
429
459
|
else:
|
430
460
|
# For non-MCP tools, preserve existing behavior
|
431
461
|
if "source_code" in update_data.keys() and not bypass_name_check:
|
432
|
-
|
462
|
+
# Check source type to use appropriate parser
|
463
|
+
source_type = update_data.get("source_type", current_tool.source_type)
|
464
|
+
if source_type == "typescript":
|
465
|
+
from letta.functions.typescript_parser import derive_typescript_json_schema
|
466
|
+
|
467
|
+
derived_schema = derive_typescript_json_schema(source_code=update_data["source_code"])
|
468
|
+
else:
|
469
|
+
# Default to Python for backwards compatibility
|
470
|
+
derived_schema = derive_openai_json_schema(source_code=update_data["source_code"])
|
471
|
+
|
433
472
|
new_name = derived_schema["name"]
|
434
473
|
if "json_schema" not in update_data.keys():
|
435
474
|
new_schema = derived_schema
|
@@ -503,8 +542,15 @@ class ToolManager:
|
|
503
542
|
# TODO: I feel like it's bad if json_schema strays from source code so
|
504
543
|
# if source code is provided, always derive the name from it
|
505
544
|
if "source_code" in update_data.keys() and not bypass_name_check:
|
506
|
-
#
|
507
|
-
|
545
|
+
# Check source type to use appropriate parser
|
546
|
+
source_type = update_data.get("source_type", current_tool.source_type)
|
547
|
+
if source_type == "typescript":
|
548
|
+
from letta.functions.typescript_parser import derive_typescript_json_schema
|
549
|
+
|
550
|
+
derived_schema = derive_typescript_json_schema(source_code=update_data["source_code"])
|
551
|
+
else:
|
552
|
+
# Default to Python for backwards compatibility
|
553
|
+
derived_schema = derive_openai_json_schema(source_code=update_data["source_code"])
|
508
554
|
new_name = derived_schema["name"]
|
509
555
|
|
510
556
|
# if json_schema wasn't provided, use the derived schema
|
@@ -570,7 +616,38 @@ class ToolManager:
|
|
570
616
|
@enforce_types
|
571
617
|
@trace_method
|
572
618
|
def upsert_base_tools(self, actor: PydanticUser) -> List[PydanticTool]:
|
573
|
-
"""
|
619
|
+
"""
|
620
|
+
Initialize or update all built-in Letta tools for a user.
|
621
|
+
|
622
|
+
This method scans predefined modules to discover and register all base tools
|
623
|
+
that ship with Letta. Tools are categorized by type (core, memory, multi-agent, etc.)
|
624
|
+
and tagged appropriately for filtering.
|
625
|
+
|
626
|
+
Args:
|
627
|
+
actor: The user to create/update tools for
|
628
|
+
|
629
|
+
Returns:
|
630
|
+
List of all base tools that were created or updated
|
631
|
+
|
632
|
+
Tool Categories Created:
|
633
|
+
- LETTA_CORE: Basic conversation tools (send_message)
|
634
|
+
- LETTA_MEMORY_CORE: Memory management (core_memory_append/replace)
|
635
|
+
- LETTA_MULTI_AGENT_CORE: Multi-agent communication tools
|
636
|
+
- LETTA_SLEEPTIME_CORE: Sleeptime agent tools
|
637
|
+
- LETTA_VOICE_SLEEPTIME_CORE: Voice agent specific tools
|
638
|
+
- LETTA_BUILTIN: Additional built-in utilities
|
639
|
+
- LETTA_FILES_CORE: File handling tools
|
640
|
+
|
641
|
+
Side Effects:
|
642
|
+
- Creates or updates tools in database
|
643
|
+
- Tools are marked with appropriate type and tags
|
644
|
+
- Existing custom tools with same names are NOT overwritten
|
645
|
+
|
646
|
+
Note:
|
647
|
+
This is typically called during user initialization or system upgrade
|
648
|
+
to ensure all base tools are available. Custom tools take precedence
|
649
|
+
over base tools with the same name.
|
650
|
+
"""
|
574
651
|
functions_to_schema = {}
|
575
652
|
|
576
653
|
for module_name in LETTA_TOOL_MODULE_NAMES:
|
@@ -164,22 +164,14 @@ class AsyncToolSandboxBase(ABC):
|
|
164
164
|
import ast
|
165
165
|
|
166
166
|
try:
|
167
|
-
# Parse the source code to AST
|
168
167
|
tree = ast.parse(self.tool.source_code)
|
169
168
|
|
170
|
-
# Look for function definitions
|
171
169
|
for node in ast.walk(tree):
|
172
170
|
if isinstance(node, ast.AsyncFunctionDef) and node.name == self.tool.name:
|
173
171
|
return True
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
# If we couldn't find the function definition, fall back to string matching
|
178
|
-
return "async def " + self.tool.name in self.tool.source_code
|
179
|
-
|
180
|
-
except SyntaxError:
|
181
|
-
# If source code can't be parsed, fall back to string matching
|
182
|
-
return "async def " + self.tool.name in self.tool.source_code
|
172
|
+
return False
|
173
|
+
except:
|
174
|
+
return False
|
183
175
|
|
184
176
|
def use_top_level_await(self) -> bool:
|
185
177
|
"""
|
@@ -0,0 +1,17 @@
|
|
1
|
+
"""Shared constants for Modal sandbox implementations."""
|
2
|
+
|
3
|
+
# Deployment and versioning
|
4
|
+
DEFAULT_CONFIG_KEY = "default"
|
5
|
+
MODAL_DEPLOYMENTS_KEY = "modal_deployments"
|
6
|
+
VERSION_HASH_LENGTH = 12
|
7
|
+
|
8
|
+
# Cache settings
|
9
|
+
CACHE_TTL_SECONDS = 60
|
10
|
+
|
11
|
+
# Modal execution settings
|
12
|
+
DEFAULT_MODAL_TIMEOUT = 60
|
13
|
+
DEFAULT_MAX_CONCURRENT_INPUTS = 1
|
14
|
+
DEFAULT_PYTHON_VERSION = "3.12"
|
15
|
+
|
16
|
+
# Security settings
|
17
|
+
SAFE_IMPORT_MODULES = {"typing", "pydantic", "datetime", "enum", "uuid", "decimal"}
|
@@ -0,0 +1,242 @@
|
|
1
|
+
"""
|
2
|
+
Modal Deployment Manager - Handles deployment orchestration with optional locking.
|
3
|
+
|
4
|
+
This module separates deployment logic from the main sandbox execution,
|
5
|
+
making it easier to understand and optionally disable locking/version tracking.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import hashlib
|
9
|
+
from typing import Tuple
|
10
|
+
|
11
|
+
import modal
|
12
|
+
|
13
|
+
from letta.log import get_logger
|
14
|
+
from letta.schemas.sandbox_config import SandboxConfig
|
15
|
+
from letta.schemas.tool import Tool
|
16
|
+
from letta.services.tool_sandbox.modal_constants import VERSION_HASH_LENGTH
|
17
|
+
from letta.services.tool_sandbox.modal_version_manager import ModalVersionManager, get_version_manager
|
18
|
+
|
19
|
+
logger = get_logger(__name__)
|
20
|
+
|
21
|
+
|
22
|
+
class ModalDeploymentManager:
|
23
|
+
"""Manages Modal app deployments with optional locking and version tracking."""
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
tool: Tool,
|
28
|
+
version_manager: ModalVersionManager | None = None,
|
29
|
+
use_locking: bool = True,
|
30
|
+
use_version_tracking: bool = True,
|
31
|
+
):
|
32
|
+
"""
|
33
|
+
Initialize deployment manager.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
tool: The tool to deploy
|
37
|
+
version_manager: Version manager for tracking deployments (optional)
|
38
|
+
use_locking: Whether to use locking for coordinated deployments
|
39
|
+
use_version_tracking: Whether to track and reuse existing deployments
|
40
|
+
"""
|
41
|
+
self.tool = tool
|
42
|
+
self.version_manager = version_manager or get_version_manager() if (use_locking or use_version_tracking) else None
|
43
|
+
self.use_locking = use_locking
|
44
|
+
self.use_version_tracking = use_version_tracking
|
45
|
+
self._app_name = self._generate_app_name()
|
46
|
+
|
47
|
+
def _generate_app_name(self) -> str:
|
48
|
+
"""Generate app name based on tool ID."""
|
49
|
+
return self.tool.id[:40]
|
50
|
+
|
51
|
+
def calculate_version_hash(self, sbx_config: SandboxConfig) -> str:
|
52
|
+
"""Calculate version hash for the current configuration."""
|
53
|
+
components = (
|
54
|
+
self.tool.source_code,
|
55
|
+
str(self.tool.pip_requirements) if self.tool.pip_requirements else "",
|
56
|
+
str(self.tool.npm_requirements) if self.tool.npm_requirements else "",
|
57
|
+
sbx_config.fingerprint(),
|
58
|
+
)
|
59
|
+
combined = "|".join(components)
|
60
|
+
return hashlib.sha256(combined.encode()).hexdigest()[:VERSION_HASH_LENGTH]
|
61
|
+
|
62
|
+
def get_full_app_name(self, version_hash: str) -> str:
|
63
|
+
"""Get the full app name including version."""
|
64
|
+
app_full_name = f"{self._app_name}-{version_hash}"
|
65
|
+
# Ensure total length is under 64 characters
|
66
|
+
if len(app_full_name) > 63:
|
67
|
+
max_id_len = 63 - len(version_hash) - 1
|
68
|
+
app_full_name = f"{self._app_name[:max_id_len]}-{version_hash}"
|
69
|
+
return app_full_name
|
70
|
+
|
71
|
+
async def get_or_deploy_app(
|
72
|
+
self,
|
73
|
+
sbx_config: SandboxConfig,
|
74
|
+
user,
|
75
|
+
create_app_func,
|
76
|
+
) -> Tuple[modal.App, str]:
|
77
|
+
"""
|
78
|
+
Get existing app or deploy new one.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
sbx_config: Sandbox configuration
|
82
|
+
user: User/actor for permissions
|
83
|
+
create_app_func: Function to create and deploy the app
|
84
|
+
|
85
|
+
Returns:
|
86
|
+
Tuple of (Modal app, version hash)
|
87
|
+
"""
|
88
|
+
version_hash = self.calculate_version_hash(sbx_config)
|
89
|
+
|
90
|
+
# Simple path: no version tracking or locking
|
91
|
+
if not self.use_version_tracking:
|
92
|
+
logger.info(f"Deploying Modal app {self._app_name} (version tracking disabled)")
|
93
|
+
app = await create_app_func(sbx_config, version_hash)
|
94
|
+
return app, version_hash
|
95
|
+
|
96
|
+
# Try to use existing deployment
|
97
|
+
if self.use_version_tracking:
|
98
|
+
existing_app = await self._try_get_existing_app(sbx_config, version_hash, user)
|
99
|
+
if existing_app:
|
100
|
+
return existing_app, version_hash
|
101
|
+
|
102
|
+
# Need to deploy - with or without locking
|
103
|
+
if self.use_locking:
|
104
|
+
return await self._deploy_with_locking(sbx_config, version_hash, user, create_app_func)
|
105
|
+
else:
|
106
|
+
return await self._deploy_without_locking(sbx_config, version_hash, user, create_app_func)
|
107
|
+
|
108
|
+
async def _try_get_existing_app(
|
109
|
+
self,
|
110
|
+
sbx_config: SandboxConfig,
|
111
|
+
version_hash: str,
|
112
|
+
user,
|
113
|
+
) -> modal.App | None:
|
114
|
+
"""Try to get an existing deployed app."""
|
115
|
+
if not self.version_manager:
|
116
|
+
return None
|
117
|
+
|
118
|
+
deployment = await self.version_manager.get_deployment(
|
119
|
+
tool_id=self.tool.id, sandbox_config_id=sbx_config.id if sbx_config else None, actor=user
|
120
|
+
)
|
121
|
+
|
122
|
+
if deployment and deployment.version_hash == version_hash:
|
123
|
+
app_full_name = self.get_full_app_name(version_hash)
|
124
|
+
logger.info(f"Checking for existing Modal app {app_full_name}")
|
125
|
+
|
126
|
+
try:
|
127
|
+
app = await modal.App.lookup.aio(app_full_name)
|
128
|
+
logger.info(f"Found existing Modal app {app_full_name}")
|
129
|
+
return app
|
130
|
+
except Exception:
|
131
|
+
logger.info(f"Modal app {app_full_name} not found in Modal, will redeploy")
|
132
|
+
return None
|
133
|
+
|
134
|
+
return None
|
135
|
+
|
136
|
+
async def _deploy_without_locking(
|
137
|
+
self,
|
138
|
+
sbx_config: SandboxConfig,
|
139
|
+
version_hash: str,
|
140
|
+
user,
|
141
|
+
create_app_func,
|
142
|
+
) -> Tuple[modal.App, str]:
|
143
|
+
"""Deploy without locking - simpler but may have race conditions."""
|
144
|
+
app_full_name = self.get_full_app_name(version_hash)
|
145
|
+
logger.info(f"Deploying Modal app {app_full_name} (no locking)")
|
146
|
+
|
147
|
+
# Deploy the app
|
148
|
+
app = await create_app_func(sbx_config, version_hash)
|
149
|
+
|
150
|
+
# Register deployment if tracking is enabled
|
151
|
+
if self.use_version_tracking and self.version_manager:
|
152
|
+
await self._register_deployment(sbx_config, version_hash, app, user)
|
153
|
+
|
154
|
+
return app, version_hash
|
155
|
+
|
156
|
+
async def _deploy_with_locking(
|
157
|
+
self,
|
158
|
+
sbx_config: SandboxConfig,
|
159
|
+
version_hash: str,
|
160
|
+
user,
|
161
|
+
create_app_func,
|
162
|
+
) -> Tuple[modal.App, str]:
|
163
|
+
"""Deploy with locking to prevent concurrent deployments."""
|
164
|
+
cache_key = f"{self.tool.id}:{sbx_config.id if sbx_config else 'default'}"
|
165
|
+
deployment_lock = self.version_manager.get_deployment_lock(cache_key)
|
166
|
+
|
167
|
+
async with deployment_lock:
|
168
|
+
# Double-check after acquiring lock
|
169
|
+
existing_app = await self._try_get_existing_app(sbx_config, version_hash, user)
|
170
|
+
if existing_app:
|
171
|
+
return existing_app, version_hash
|
172
|
+
|
173
|
+
# Check if another process is deploying
|
174
|
+
if self.version_manager.is_deployment_in_progress(cache_key, version_hash):
|
175
|
+
logger.info(f"Another process is deploying {self._app_name} v{version_hash}, waiting...")
|
176
|
+
# Release lock and wait
|
177
|
+
deployment_lock = None
|
178
|
+
|
179
|
+
# Wait for other deployment if needed
|
180
|
+
if deployment_lock is None:
|
181
|
+
success = await self.version_manager.wait_for_deployment(cache_key, version_hash, timeout=120)
|
182
|
+
if success:
|
183
|
+
existing_app = await self._try_get_existing_app(sbx_config, version_hash, user)
|
184
|
+
if existing_app:
|
185
|
+
return existing_app, version_hash
|
186
|
+
raise RuntimeError(f"Deployment completed but app not found")
|
187
|
+
else:
|
188
|
+
raise RuntimeError(f"Timeout waiting for deployment")
|
189
|
+
|
190
|
+
# We're deploying - mark as in progress
|
191
|
+
deployment_key = None
|
192
|
+
async with deployment_lock:
|
193
|
+
deployment_key = self.version_manager.mark_deployment_in_progress(cache_key, version_hash)
|
194
|
+
|
195
|
+
try:
|
196
|
+
app_full_name = self.get_full_app_name(version_hash)
|
197
|
+
logger.info(f"Deploying Modal app {app_full_name} with locking")
|
198
|
+
|
199
|
+
# Deploy the app
|
200
|
+
app = await create_app_func(sbx_config, version_hash)
|
201
|
+
|
202
|
+
# Mark deployment complete
|
203
|
+
if deployment_key:
|
204
|
+
self.version_manager.complete_deployment(deployment_key)
|
205
|
+
|
206
|
+
# Register deployment
|
207
|
+
if self.use_version_tracking:
|
208
|
+
await self._register_deployment(sbx_config, version_hash, app, user)
|
209
|
+
|
210
|
+
return app, version_hash
|
211
|
+
|
212
|
+
except Exception:
|
213
|
+
if deployment_key:
|
214
|
+
self.version_manager.complete_deployment(deployment_key)
|
215
|
+
raise
|
216
|
+
|
217
|
+
async def _register_deployment(
|
218
|
+
self,
|
219
|
+
sbx_config: SandboxConfig,
|
220
|
+
version_hash: str,
|
221
|
+
app: modal.App,
|
222
|
+
user,
|
223
|
+
):
|
224
|
+
if not self.version_manager:
|
225
|
+
return
|
226
|
+
|
227
|
+
dependencies = set()
|
228
|
+
if self.tool.pip_requirements:
|
229
|
+
dependencies.update(str(req) for req in self.tool.pip_requirements)
|
230
|
+
modal_config = sbx_config.get_modal_config()
|
231
|
+
if modal_config.pip_requirements:
|
232
|
+
dependencies.update(str(req) for req in modal_config.pip_requirements)
|
233
|
+
|
234
|
+
await self.version_manager.register_deployment(
|
235
|
+
tool_id=self.tool.id,
|
236
|
+
app_name=self._app_name,
|
237
|
+
version_hash=version_hash,
|
238
|
+
app=app,
|
239
|
+
dependencies=dependencies,
|
240
|
+
sandbox_config_id=sbx_config.id if sbx_config else None,
|
241
|
+
actor=user,
|
242
|
+
)
|