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.
Files changed (90) hide show
  1. letta/__init__.py +1 -1
  2. letta/agents/helpers.py +4 -0
  3. letta/agents/letta_agent.py +142 -5
  4. letta/constants.py +10 -7
  5. letta/data_sources/connectors.py +70 -53
  6. letta/embeddings.py +3 -240
  7. letta/errors.py +28 -0
  8. letta/functions/function_sets/base.py +4 -4
  9. letta/functions/functions.py +287 -32
  10. letta/functions/mcp_client/types.py +11 -0
  11. letta/functions/schema_validator.py +187 -0
  12. letta/functions/typescript_parser.py +196 -0
  13. letta/helpers/datetime_helpers.py +8 -4
  14. letta/helpers/tool_execution_helper.py +25 -2
  15. letta/llm_api/anthropic_client.py +23 -18
  16. letta/llm_api/azure_client.py +73 -0
  17. letta/llm_api/bedrock_client.py +8 -4
  18. letta/llm_api/google_vertex_client.py +14 -5
  19. letta/llm_api/llm_api_tools.py +2 -217
  20. letta/llm_api/llm_client.py +15 -1
  21. letta/llm_api/llm_client_base.py +32 -1
  22. letta/llm_api/openai.py +1 -0
  23. letta/llm_api/openai_client.py +18 -28
  24. letta/llm_api/together_client.py +55 -0
  25. letta/orm/provider.py +1 -0
  26. letta/orm/step_metrics.py +40 -1
  27. letta/otel/db_pool_monitoring.py +1 -1
  28. letta/schemas/agent.py +3 -4
  29. letta/schemas/agent_file.py +2 -0
  30. letta/schemas/block.py +11 -5
  31. letta/schemas/embedding_config.py +4 -5
  32. letta/schemas/enums.py +1 -1
  33. letta/schemas/job.py +2 -3
  34. letta/schemas/llm_config.py +79 -7
  35. letta/schemas/mcp.py +0 -24
  36. letta/schemas/message.py +0 -108
  37. letta/schemas/openai/chat_completion_request.py +1 -0
  38. letta/schemas/providers/__init__.py +0 -2
  39. letta/schemas/providers/anthropic.py +106 -8
  40. letta/schemas/providers/azure.py +102 -8
  41. letta/schemas/providers/base.py +10 -3
  42. letta/schemas/providers/bedrock.py +28 -16
  43. letta/schemas/providers/letta.py +3 -3
  44. letta/schemas/providers/ollama.py +2 -12
  45. letta/schemas/providers/openai.py +4 -4
  46. letta/schemas/providers/together.py +14 -2
  47. letta/schemas/sandbox_config.py +2 -1
  48. letta/schemas/tool.py +46 -22
  49. letta/server/rest_api/routers/v1/agents.py +179 -38
  50. letta/server/rest_api/routers/v1/folders.py +13 -8
  51. letta/server/rest_api/routers/v1/providers.py +10 -3
  52. letta/server/rest_api/routers/v1/sources.py +14 -8
  53. letta/server/rest_api/routers/v1/steps.py +17 -1
  54. letta/server/rest_api/routers/v1/tools.py +96 -5
  55. letta/server/rest_api/streaming_response.py +91 -45
  56. letta/server/server.py +27 -38
  57. letta/services/agent_manager.py +92 -20
  58. letta/services/agent_serialization_manager.py +11 -7
  59. letta/services/context_window_calculator/context_window_calculator.py +40 -2
  60. letta/services/helpers/agent_manager_helper.py +73 -12
  61. letta/services/mcp_manager.py +109 -15
  62. letta/services/passage_manager.py +28 -109
  63. letta/services/provider_manager.py +24 -0
  64. letta/services/step_manager.py +68 -0
  65. letta/services/summarizer/summarizer.py +1 -4
  66. letta/services/tool_executor/core_tool_executor.py +1 -1
  67. letta/services/tool_executor/sandbox_tool_executor.py +26 -9
  68. letta/services/tool_manager.py +82 -5
  69. letta/services/tool_sandbox/base.py +3 -11
  70. letta/services/tool_sandbox/modal_constants.py +17 -0
  71. letta/services/tool_sandbox/modal_deployment_manager.py +242 -0
  72. letta/services/tool_sandbox/modal_sandbox.py +218 -3
  73. letta/services/tool_sandbox/modal_sandbox_v2.py +429 -0
  74. letta/services/tool_sandbox/modal_version_manager.py +273 -0
  75. letta/services/tool_sandbox/safe_pickle.py +193 -0
  76. letta/settings.py +5 -3
  77. letta/templates/sandbox_code_file.py.j2 +2 -4
  78. letta/templates/sandbox_code_file_async.py.j2 +2 -4
  79. letta/utils.py +1 -1
  80. {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/METADATA +2 -2
  81. {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/RECORD +84 -81
  82. letta/llm_api/anthropic.py +0 -1206
  83. letta/llm_api/aws_bedrock.py +0 -104
  84. letta/llm_api/azure_openai.py +0 -118
  85. letta/llm_api/azure_openai_constants.py +0 -11
  86. letta/llm_api/cohere.py +0 -391
  87. letta/schemas/providers/cohere.py +0 -18
  88. {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/LICENSE +0 -0
  89. {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/WHEEL +0 -0
  90. {letta_nightly-0.11.3.dev20250820104219.dist-info → letta_nightly-0.11.4.dev20250821104215.dist-info}/entry_points.txt +0 -0
@@ -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
- print("request data:", request_data)
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().insert_passage_async(
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
- sandbox = AsyncToolSandboxModal(
62
- function_name, function_args, actor, tool_object=tool, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars
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
@@ -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
- """Update a tool by its ID with the given ToolUpdate object."""
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
- derived_schema = derive_openai_json_schema(source_code=update_data["source_code"])
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
- # derive the schema from source code to get the function name
507
- derived_schema = derive_openai_json_schema(source_code=update_data["source_code"])
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
- """Add default tools in base.py and multi_agent.py"""
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
- elif isinstance(node, ast.FunctionDef) and node.name == self.tool.name:
175
- return False
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
+ )