import importlib from typing import List, Optional, Set, Union from pydantic import ValidationError from sqlalchemy import and_, func, or_, select from letta.constants import ( BASE_FUNCTION_RETURN_CHAR_LIMIT, BASE_MEMORY_TOOLS, BASE_SLEEPTIME_TOOLS, BASE_TOOLS, BASE_VOICE_SLEEPTIME_CHAT_TOOLS, BASE_VOICE_SLEEPTIME_TOOLS, BUILTIN_TOOLS, FILES_TOOLS, LETTA_PARALLEL_SAFE_TOOLS, LETTA_TOOL_MODULE_NAMES, LETTA_TOOL_SET, LOCAL_ONLY_MULTI_AGENT_TOOLS, MAX_TOOL_NAME_LENGTH, MCP_TOOL_TAG_NAME_PREFIX, MODAL_DEFAULT_PYTHON_VERSION, MODAL_DEFAULT_TOOL_NAME, MODAL_SAFE_IMPORT_MODULES, ) from letta.errors import LettaInvalidArgumentError, LettaToolNameConflictError, LettaToolNameSchemaMismatchError from letta.functions.functions import derive_openai_json_schema, load_function_set from letta.helpers.tool_helpers import compute_tool_hash, generate_modal_function_name from letta.log import get_logger # TODO: Remove this once we translate all of these to the ORM from letta.orm.errors import NoResultFound from letta.orm.tool import Tool as ToolModel from letta.otel.tracing import trace_method from letta.schemas.agent import AgentState from letta.schemas.enums import PrimitiveType, SandboxType, ToolType from letta.schemas.tool import Tool as PydanticTool, ToolCreate, ToolUpdate from letta.schemas.user import User as PydanticUser from letta.server.db import db_registry from letta.services.helpers.agent_manager_helper import calculate_multi_agent_tools from letta.services.mcp.types import SSEServerConfig, StdioServerConfig from letta.services.tool_schema_generator import generate_schema_for_tool_creation, generate_schema_for_tool_update from letta.settings import settings, tool_settings from letta.utils import enforce_types, fire_and_forget, printd from letta.validators import raise_on_invalid_id logger = get_logger(__name__) # NOTE: function name and nested modal function decorator name must stay in sync with MODAL_DEFAULT_TOOL_NAME def modal_tool_wrapper(tool: PydanticTool, actor: PydanticUser, sandbox_env_vars: dict = None, project_id: str = "default"): """Create a Modal function wrapper for a tool""" import contextlib import io import os import sys from typing import Optional import modal from letta_client import Letta packages = [str(req) for req in tool.pip_requirements] if tool.pip_requirements else [] for package in MODAL_SAFE_IMPORT_MODULES: packages.append(package) packages.append("letta_client>=1.1.1") packages.append("letta") # Base letta without extras packages.append("asyncpg>=0.30.0") # Fixes asyncpg import error packages.append("psycopg2-binary>=2.9.10") # PostgreSQL adapter (pre-compiled, no build required) # packages.append("pgvector>=0.3.6") # Vector operations support function_name = generate_modal_function_name(tool.name, actor.organization_id, project_id) modal_app = modal.App(function_name) logger.info(f"Creating Modal app {tool.id} with name {function_name}") # Create secrets dict with sandbox env vars secrets_dict = {"LETTA_API_KEY": None} if sandbox_env_vars: secrets_dict.update(sandbox_env_vars) @modal_app.function( image=modal.Image.debian_slim(python_version=MODAL_DEFAULT_PYTHON_VERSION).pip_install(packages), restrict_modal_access=True, timeout=10, secrets=[modal.Secret.from_dict(secrets_dict)], serialized=True, ) def modal_function( tool_name: str, agent_state: Optional[dict], agent_id: Optional[str], env_vars: dict, letta_api_key: Optional[str] = None, **kwargs ): """Wrapper function for running untrusted code in a Modal function""" # Reconstruct AgentState from dict if passed (to avoid cloudpickle serialization issues) # This is done with extra safety to handle schema mismatches between environments reconstructed_agent_state = None if agent_state: try: from letta.schemas.agent import AgentState as AgentStateModel # Filter dict to only include fields that exist in Modal's version of AgentState # This prevents ValidationError from extra fields in newer schemas modal_agent_fields = set(AgentStateModel.model_fields.keys()) filtered_agent_state = {key: value for key, value in agent_state.items() if key in modal_agent_fields} # Try to reconstruct with filtered data reconstructed_agent_state = AgentStateModel.model_validate(filtered_agent_state) # Log if we filtered out any fields filtered_out = set(agent_state.keys()) - modal_agent_fields if filtered_out: print(f"Fields not in available in AgentState: {filtered_out}", file=sys.stderr) except ImportError as e: print(f"Cannot import AgentState: {e}", file=sys.stderr) print("Passing agent_state as dict to tool", file=sys.stderr) reconstructed_agent_state = agent_state except Exception as e: print(f"Warning: Could not reconstruct AgentState (schema mismatch?): {e}", file=sys.stderr) print("Passing agent_state as dict to tool", file=sys.stderr) reconstructed_agent_state = agent_state if env_vars: for key, value in env_vars.items(): os.environ[key] = str(value) # TODO: directly instantiate the letta client once we upgrade to 1.0.0+ in core # Initialize the Letta client # if letta_api_key: # letta_client = Letta(token=letta_api_key, base_url=os.environ.get("LETTA_API_URL", "https://api.letta.com")) # else: # letta_client = None tool_namespace = { "__builtins__": __builtins__, # Include built-in functions # "_letta_client": letta_client, # Make letta_client available "os": os, # Include os module for env vars access "agent_id": agent_id, "_LETTA_API_KEY": letta_api_key, # Add any other modules/variables the tool might need } # Initialize the tool code # Create a namespace for the tool # tool_namespace = {} exec(tool.source_code, tool_namespace) # Get the tool function if tool_name not in tool_namespace: raise Exception(f"Tool function {tool_name} not found in {tool.source_code}, globals: {tool_namespace}") tool_func = tool_namespace[tool_name] # Detect if the tool function is async import asyncio import inspect import traceback is_async = inspect.iscoroutinefunction(tool_func) # Capture stdout and stderr during tool execution stdout_capture = io.StringIO() stderr_capture = io.StringIO() result = None error_occurred = False with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture): try: # if `agent_state` is in the tool function arguments, inject it # Pass reconstructed AgentState (or dict if reconstruction failed) if "agent_state" in tool_func.__code__.co_varnames: kwargs["agent_state"] = reconstructed_agent_state # Execute the tool function (async or sync) if is_async: result = asyncio.run(tool_func(**kwargs)) else: result = tool_func(**kwargs) except Exception as e: # Capture the exception and write to stderr error_occurred = True traceback.print_exc(file=stderr_capture) # Get captured output stdout = stdout_capture.getvalue() stderr = stderr_capture.getvalue() return { "result": result, "stdout": stdout, "stderr": stderr, "agent_state": agent_state, # TODO: deprecate (use letta_client instead) "error": error_occurred or bool(stderr), } return modal_app class ToolManager: """Manager class to handle business logic related to Tools.""" @enforce_types @trace_method async def create_or_update_tool_async( self, pydantic_tool: PydanticTool, actor: PydanticUser, bypass_name_check: bool = False, modal_sandbox_enabled: bool = False ) -> PydanticTool: """Create a new tool based on the ToolCreate schema.""" if pydantic_tool.tool_type == ToolType.CUSTOM and not pydantic_tool.json_schema: generated_schema = generate_schema_for_tool_creation(pydantic_tool) if generated_schema: pydantic_tool.json_schema = generated_schema else: raise ValueError("Failed to generate schema for tool", pydantic_tool.source_code) # make sure the name matches the json_schema if not pydantic_tool.name: pydantic_tool.name = pydantic_tool.json_schema.get("name") else: # if name is provided, make sure its less tahn the MAX_TOOL_NAME_LENGTH if len(pydantic_tool.name) > MAX_TOOL_NAME_LENGTH: raise LettaInvalidArgumentError( f"Tool name {pydantic_tool.name} is too long. It must be less than {MAX_TOOL_NAME_LENGTH} characters." ) if pydantic_tool.name != pydantic_tool.json_schema.get("name"): raise LettaToolNameSchemaMismatchError( tool_name=pydantic_tool.name, json_schema_name=pydantic_tool.json_schema.get("name"), source_code=pydantic_tool.source_code, ) # check if the tool name already exists current_tool = await self.get_tool_by_name_async(tool_name=pydantic_tool.name, actor=actor) if current_tool: # Put to dict and remove fields that should not be reset update_data = pydantic_tool.model_dump(exclude_unset=True, exclude_none=True) update_data["organization_id"] = actor.organization_id # If there's anything to update if update_data: # In case we want to update the tool type # Useful if we are shuffling around base tools updated_tool_type = None if "tool_type" in update_data: updated_tool_type = update_data.get("tool_type") tool = await self.update_tool_by_id_async( current_tool.id, ToolUpdate(**update_data), actor, updated_tool_type=updated_tool_type, modal_sandbox_enabled=modal_sandbox_enabled, ) else: printd( f"`create_or_update_tool` was called with user_id={actor.id}, organization_id={actor.organization_id}, name={pydantic_tool.name}, but found existing tool with nothing to update." ) tool = await self.get_tool_by_id_async(current_tool.id, actor=actor) return tool return await self.create_tool_async(pydantic_tool, actor=actor, modal_sandbox_enabled=modal_sandbox_enabled) @enforce_types async def create_mcp_server( self, server_config: Union[StdioServerConfig, SSEServerConfig], actor: PydanticUser ) -> List[Union[StdioServerConfig, SSEServerConfig]]: pass @enforce_types async def create_mcp_tool_async( self, tool_create: ToolCreate, mcp_server_name: str, mcp_server_id: str, actor: PydanticUser ) -> PydanticTool: metadata = {MCP_TOOL_TAG_NAME_PREFIX: {"server_name": mcp_server_name, "server_id": mcp_server_id}} return await self.create_or_update_tool_async( PydanticTool( tool_type=ToolType.EXTERNAL_MCP, name=tool_create.json_schema["name"], metadata_=metadata, **tool_create.model_dump() ), actor, ) @enforce_types @trace_method async def create_or_update_mcp_tool_async( self, tool_create: ToolCreate, mcp_server_name: str, mcp_server_id: str, actor: PydanticUser ) -> PydanticTool: metadata = {MCP_TOOL_TAG_NAME_PREFIX: {"server_name": mcp_server_name, "server_id": mcp_server_id}} return await self.create_or_update_tool_async( PydanticTool( tool_type=ToolType.EXTERNAL_MCP, name=tool_create.json_schema["name"], metadata_=metadata, **tool_create.model_dump() ), actor, ) @enforce_types @trace_method async def create_tool_async( self, pydantic_tool: PydanticTool, actor: PydanticUser, modal_sandbox_enabled: bool = False ) -> PydanticTool: """Create a new tool based on the ToolCreate schema.""" # Generate schema only if not provided (only for custom tools) async with db_registry.async_session() as session: # Auto-generate description if not provided if pydantic_tool.description is None and pydantic_tool.json_schema: pydantic_tool.description = pydantic_tool.json_schema.get("description", None) # Add sandbox:modal to metadata if flag is enabled if modal_sandbox_enabled: if pydantic_tool.metadata_ is None: pydantic_tool.metadata_ = {} pydantic_tool.metadata_["sandbox"] = "modal" # Add tool hash to metadata for Modal deployment tracking tool_hash = compute_tool_hash(pydantic_tool) if pydantic_tool.metadata_ is None: pydantic_tool.metadata_ = {} pydantic_tool.metadata_["tool_hash"] = tool_hash tool_data = pydantic_tool.model_dump(to_orm=True) # Set the organization id at the ORM layer tool_data["organization_id"] = actor.organization_id tool = ToolModel(**tool_data) # Log tool creation with memory footprint import sys tool_size_kb = sys.getsizeof(tool_data) / 1024 source_code_size_kb = len(pydantic_tool.source_code or "") / 1024 schema_size_kb = len(str(pydantic_tool.json_schema or "")) / 1024 logger.info( f"Creating tool '{pydantic_tool.name}': total {tool_size_kb:.2f} KB (source: {source_code_size_kb:.2f} KB, schema: {schema_size_kb:.2f} KB)" ) await tool.create_async(session, actor=actor) # Re-raise other database-related errors created_tool = tool.to_pydantic() # Deploy Modal app for the new tool # Both Modal credentials configured AND tool metadata must indicate Modal tool_requests_modal = created_tool.metadata_ and created_tool.metadata_.get("sandbox") == "modal" modal_configured = tool_settings.modal_sandbox_enabled if created_tool.tool_type == ToolType.CUSTOM and tool_requests_modal and modal_configured: await self.create_or_update_modal_app(created_tool, actor) # Embed tool in Turbopuffer if enabled from letta.helpers.tpuf_client import should_use_tpuf_for_tools if should_use_tpuf_for_tools(): fire_and_forget( self._embed_tool_background(created_tool, actor), task_name=f"embed_tool_{created_tool.id}", ) return created_tool @enforce_types @trace_method async def bulk_upsert_tools_async( self, pydantic_tools: List[PydanticTool], actor: PydanticUser, override_existing_tools: bool = True ) -> List[PydanticTool]: """ Bulk create or update multiple tools in a single database transaction. Uses optimized PostgreSQL bulk upsert when available, falls back to individual upserts for SQLite. This is much more efficient than calling create_or_update_tool_async in a loop. IMPORTANT BEHAVIOR NOTES: - Tools are matched by (name, organization_id) unique constraint, NOT by ID - If a tool with the same name already exists for the organization, it will be updated regardless of any ID provided in the input tool - The existing tool's ID is preserved during updates - If you provide a tool with an explicit ID but a name that matches an existing tool, the existing tool will be updated and the provided ID will be ignored - This matches the behavior of create_or_update_tool_async which also matches by name PostgreSQL optimization: - Uses native ON CONFLICT (name, organization_id) DO UPDATE for atomic upserts - All tools are processed in a single SQL statement for maximum efficiency SQLite fallback: - Falls back to individual create_or_update_tool_async calls - Still benefits from batched transaction handling Args: pydantic_tools: List of tools to create or update actor: User performing the action Returns: List of created/updated tools """ if not pydantic_tools: return [] # get schemas if not provided for tool in pydantic_tools: if tool.json_schema is None: tool.json_schema = generate_schema_for_tool_creation(tool) # auto-generate descriptions if not provided for tool in pydantic_tools: if tool.description is None: tool.description = tool.json_schema.get("description", None) # Log bulk tool operation total_source_code_kb = sum(len(t.source_code or "") for t in pydantic_tools) / 1024 logger.info(f"Bulk upserting {len(pydantic_tools)} tools: total source code {total_source_code_kb:.2f} KB") if settings.letta_pg_uri_no_default: # use optimized postgresql bulk upsert async with db_registry.async_session() as session: return await self._bulk_upsert_postgresql(session, pydantic_tools, actor, override_existing_tools) else: # fallback to individual upserts for sqlite return await self._upsert_tools_individually(pydantic_tools, actor, override_existing_tools) @enforce_types @trace_method @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL) async def get_tool_by_id_async(self, tool_id: str, actor: PydanticUser) -> PydanticTool: """Fetch a tool by its ID.""" async with db_registry.async_session() as session: # Retrieve tool by id using the Tool model's read method tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor) # Convert the SQLAlchemy Tool object to PydanticTool return tool.to_pydantic() @enforce_types @trace_method async def get_tool_by_name_async(self, tool_name: str, actor: PydanticUser) -> Optional[PydanticTool]: """Retrieve a tool by its name and a user. We derive the organization from the user, and retrieve that tool.""" try: async with db_registry.async_session() as session: tool = await ToolModel.read_async(db_session=session, name=tool_name, actor=actor) return tool.to_pydantic() except NoResultFound: return None @enforce_types @trace_method async def get_tool_id_by_name_async(self, tool_name: str, actor: PydanticUser) -> Optional[str]: """Retrieve a tool by its name and a user. We derive the organization from the user, and retrieve that tool.""" try: async with db_registry.async_session() as session: tool = await ToolModel.read_async(db_session=session, name=tool_name, actor=actor) return tool.id except NoResultFound: return None @enforce_types @trace_method @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL) async def tool_exists_async(self, tool_id: str, actor: PydanticUser) -> bool: """Check if a tool exists and belongs to the user's organization (lightweight check).""" async with db_registry.async_session() as session: query = select(func.count(ToolModel.id)).where(ToolModel.id == tool_id, ToolModel.organization_id == actor.organization_id) result = await session.execute(query) count = result.scalar() return count > 0 @enforce_types @trace_method async def tool_name_exists_async(self, tool_name: str, actor: PydanticUser) -> bool: """Check if a tool with the given name exists in the user's organization (lightweight check).""" async with db_registry.async_session() as session: query = select(func.count(ToolModel.id)).where(ToolModel.name == tool_name, ToolModel.organization_id == actor.organization_id) result = await session.execute(query) count = result.scalar() return count > 0 @enforce_types @trace_method async def list_tools_async( self, actor: PydanticUser, before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 50, ascending: bool = False, upsert_base_tools: bool = True, tool_types: Optional[List[str]] = None, exclude_tool_types: Optional[List[str]] = None, names: Optional[List[str]] = None, tool_ids: Optional[List[str]] = None, search: Optional[str] = None, return_only_letta_tools: bool = False, ) -> List[PydanticTool]: """List all tools with pagination support.""" tools = await self._list_tools_async( actor=actor, before=before, after=after, limit=limit, ascending=ascending, tool_types=tool_types, exclude_tool_types=exclude_tool_types, names=names, tool_ids=tool_ids, search=search, return_only_letta_tools=return_only_letta_tools, ) # Check if all base tools are present if we requested all the tools w/o cursor # TODO: This is a temporary hack to resolve this issue # TODO: This requires a deeper rethink about how we keep all our internal tools up-to-date if not after and upsert_base_tools: existing_tool_names = {tool.name for tool in tools} base_tool_names = LETTA_TOOL_SET - set(LOCAL_ONLY_MULTI_AGENT_TOOLS) if settings.environment == "PRODUCTION" else LETTA_TOOL_SET missing_base_tools = base_tool_names - existing_tool_names # If any base tools are missing, upsert all base tools if missing_base_tools: logger.info(f"Missing base tools detected: {missing_base_tools}. Upserting all base tools.") await self.upsert_base_tools_async(actor=actor) # Re-fetch the tools list after upserting base tools tools = await self._list_tools_async( actor=actor, before=before, after=after, limit=limit, ascending=ascending, tool_types=tool_types, exclude_tool_types=exclude_tool_types, names=names, tool_ids=tool_ids, search=search, return_only_letta_tools=return_only_letta_tools, ) return tools @enforce_types @trace_method async def _list_tools_async( self, actor: PydanticUser, before: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = 50, ascending: bool = False, tool_types: Optional[List[str]] = None, exclude_tool_types: Optional[List[str]] = None, names: Optional[List[str]] = None, tool_ids: Optional[List[str]] = None, search: Optional[str] = None, return_only_letta_tools: bool = False, ) -> List[PydanticTool]: """List all tools with optional pagination.""" tools_to_delete = [] async with db_registry.async_session() as session: # Use SQLAlchemy directly for all cases - more control and consistency # Start with base query query = select(ToolModel).where(ToolModel.organization_id == actor.organization_id) # Apply tool_types filter if tool_types is not None: query = query.where(ToolModel.tool_type.in_(tool_types)) # Apply names filter if names is not None: query = query.where(ToolModel.name.in_(names)) # Apply tool_ids filter if tool_ids is not None: query = query.where(ToolModel.id.in_(tool_ids)) # Apply search filter (ILIKE for case-insensitive partial match) if search is not None: query = query.where(ToolModel.name.ilike(f"%{search}%")) # Apply exclude_tool_types filter at database level if exclude_tool_types is not None: query = query.where(~ToolModel.tool_type.in_(exclude_tool_types)) # Apply return_only_letta_tools filter at database level if return_only_letta_tools: query = query.where(ToolModel.tool_type.like("letta_%")) # Handle pagination cursors if after is not None: after_tool = await session.get(ToolModel, after) if after_tool: if ascending: query = query.where( or_( ToolModel.created_at > after_tool.created_at, and_(ToolModel.created_at == after_tool.created_at, ToolModel.id > after_tool.id), ) ) else: query = query.where( or_( ToolModel.created_at < after_tool.created_at, and_(ToolModel.created_at == after_tool.created_at, ToolModel.id < after_tool.id), ) ) if before is not None: before_tool = await session.get(ToolModel, before) if before_tool: if ascending: query = query.where( or_( ToolModel.created_at < before_tool.created_at, and_(ToolModel.created_at == before_tool.created_at, ToolModel.id < before_tool.id), ) ) else: query = query.where( or_( ToolModel.created_at > before_tool.created_at, and_(ToolModel.created_at == before_tool.created_at, ToolModel.id > before_tool.id), ) ) # Apply limit if limit is not None: query = query.limit(limit) # Apply ordering based on ascending parameter if ascending: query = query.order_by(ToolModel.created_at.asc(), ToolModel.id.asc()) else: query = query.order_by(ToolModel.created_at.desc(), ToolModel.id.desc()) # Execute query result = await session.execute(query) tools = list(result.scalars()) # Remove any malformed tools results = [] for tool in tools: try: pydantic_tool = tool.to_pydantic() results.append(pydantic_tool) except (ValueError, ModuleNotFoundError, AttributeError) as e: tools_to_delete.append(tool) logger.warning( "Deleting malformed tool with id=%s and name=%s. Error was:\n%s\nDeleted tool:%s", tool.id, tool.name, e, tool.pretty_print_columns(), ) for tool in tools_to_delete: await self.delete_tool_by_id_async(tool.id, actor=actor) return results @enforce_types @trace_method async def count_tools_async( self, actor: PydanticUser, tool_types: Optional[List[str]] = None, exclude_tool_types: Optional[List[str]] = None, names: Optional[List[str]] = None, tool_ids: Optional[List[str]] = None, search: Optional[str] = None, return_only_letta_tools: bool = False, exclude_letta_tools: bool = False, ) -> int: """Count tools with the same filtering logic as list_tools_async.""" async with db_registry.async_session() as session: # Use SQLAlchemy directly with COUNT query - same filtering logic as list_tools_async # Start with base query query = select(func.count(ToolModel.id)).where(ToolModel.organization_id == actor.organization_id) # Apply tool_types filter if tool_types is not None: query = query.where(ToolModel.tool_type.in_(tool_types)) # Apply names filter if names is not None: query = query.where(ToolModel.name.in_(names)) # Apply tool_ids filter if tool_ids is not None: query = query.where(ToolModel.id.in_(tool_ids)) # Apply search filter (ILIKE for case-insensitive partial match) if search is not None: query = query.where(ToolModel.name.ilike(f"%{search}%")) # Apply exclude_tool_types filter at database level if exclude_tool_types is not None: query = query.where(~ToolModel.tool_type.in_(exclude_tool_types)) # Apply return_only_letta_tools filter at database level if return_only_letta_tools: query = query.where(ToolModel.tool_type.like("letta_%")) # Handle exclude_letta_tools logic (if True, exclude Letta tools) if exclude_letta_tools: # Exclude tools that are in the LETTA_TOOL_SET letta_tool_names = list(LETTA_TOOL_SET) query = query.where(~ToolModel.name.in_(letta_tool_names)) # Execute count query result = await session.execute(query) count = result.scalar() return count or 0 @enforce_types @trace_method async def size_async( self, actor: PydanticUser, include_base_tools: bool, ) -> int: """ Get the total count of tools for the given user. If include_builtin is True, it will also count the built-in tools. """ async with db_registry.async_session() as session: if include_base_tools: return await ToolModel.size_async(db_session=session, actor=actor) return await ToolModel.size_async(db_session=session, actor=actor, name=LETTA_TOOL_SET) @enforce_types @trace_method @raise_on_invalid_id(param_name="tool_id", expected_prefix=PrimitiveType.TOOL) async def update_tool_by_id_async( self, tool_id: str, tool_update: ToolUpdate, actor: PydanticUser, updated_tool_type: Optional[ToolType] = None, bypass_name_check: bool = False, modal_sandbox_enabled: bool = False, ) -> PydanticTool: """Update a tool by its ID with the given ToolUpdate object.""" # Fetch current tool early to allow conditional logic based on tool type current_tool = await self.get_tool_by_id_async(tool_id=tool_id, actor=actor) # Handle schema updates for custom tools new_schema = None if current_tool.tool_type == ToolType.CUSTOM: if tool_update.json_schema is not None: new_schema = tool_update.json_schema elif tool_update.args_json_schema is not None: # Generate full schema from args_json_schema generated_schema = generate_schema_for_tool_update( current_tool=current_tool, json_schema=None, args_json_schema=tool_update.args_json_schema, source_code=tool_update.source_code, source_type=tool_update.source_type, ) if generated_schema: tool_update.json_schema = generated_schema new_schema = generated_schema # Now model_dump with the potentially updated schema update_data = tool_update.model_dump(to_orm=True, exclude_none=True) # Determine the final schema and name if new_schema: new_name = new_schema.get("name", current_tool.name) elif "json_schema" in update_data: new_schema = update_data["json_schema"] new_name = new_schema.get("name", current_tool.name) else: # Keep existing schema new_schema = current_tool.json_schema new_name = current_tool.name # Handle explicit name updates if "name" in update_data and update_data["name"] != current_tool.name: # Name is being explicitly changed new_name = update_data["name"] # Update the json_schema name to match if there's a schema if new_schema: new_schema = new_schema.copy() new_schema["name"] = new_name update_data["json_schema"] = new_schema elif new_schema and new_name != current_tool.name: # Schema provides a different name but name wasn't explicitly changed update_data["name"] = new_name # raise ValueError( # f"JSON schema name '{new_name}' conflicts with current tool name '{current_tool.name}'. Update the name field explicitly if you want to rename the tool." # ) # If name changes, enforce uniqueness if new_name != current_tool.name: name_exists = await self.tool_name_exists_async(tool_name=new_name, actor=actor) if name_exists: raise LettaToolNameConflictError(tool_name=new_name) # NOTE: EXTREMELEY HACKY, we need to stop making assumptions about the source_code if "source_code" in update_data and f"def {new_name}" not in update_data.get("source_code", ""): raise LettaToolNameSchemaMismatchError( tool_name=new_name, json_schema_name=new_schema.get("name") if new_schema else None, source_code=update_data.get("source_code"), ) # Create a preview of the updated tool by merging current tool with updates # This allows us to compute the hash before the database session updated_tool_pydantic = current_tool.model_copy(deep=True) for key, value in update_data.items(): setattr(updated_tool_pydantic, key, value) if new_schema is not None: updated_tool_pydantic.json_schema = new_schema updated_tool_pydantic.name = new_name if updated_tool_type: updated_tool_pydantic.tool_type = updated_tool_type # Handle sandbox:modal metadata based on flag if modal_sandbox_enabled: # Add sandbox:modal to metadata if flag is enabled if updated_tool_pydantic.metadata_ is None: updated_tool_pydantic.metadata_ = {} updated_tool_pydantic.metadata_["sandbox"] = "modal" # Update the update_data to reflect this change if metadata was in the update if "metadata_" not in update_data: update_data["metadata_"] = updated_tool_pydantic.metadata_ else: update_data["metadata_"]["sandbox"] = "modal" else: # Remove sandbox:modal from metadata if flag is not enabled if updated_tool_pydantic.metadata_ and updated_tool_pydantic.metadata_.get("sandbox") == "modal": updated_tool_pydantic.metadata_ = {k: v for k, v in updated_tool_pydantic.metadata_.items() if k != "sandbox"} if not updated_tool_pydantic.metadata_: # If metadata becomes empty, set to None updated_tool_pydantic.metadata_ = None # Update the update_data to reflect this change update_data["metadata_"] = updated_tool_pydantic.metadata_ # Check if we need to redeploy the Modal app due to changes # Compute this before the session to avoid issues tool_requests_modal = updated_tool_pydantic.metadata_ and updated_tool_pydantic.metadata_.get("sandbox") == "modal" modal_configured = tool_settings.modal_sandbox_enabled should_check_modal = tool_requests_modal and modal_configured and updated_tool_pydantic.tool_type == ToolType.CUSTOM # Compute hash before session if needed new_hash = None old_hash = None needs_modal_deployment = False if should_check_modal: new_hash = compute_tool_hash(updated_tool_pydantic) old_hash = current_tool.metadata_.get("tool_hash") if current_tool.metadata_ else None needs_modal_deployment = new_hash != old_hash # Now perform the update within the session async with db_registry.async_session() as session: # Fetch the tool by ID tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor) # Update tool attributes with only the fields that were explicitly set for key, value in update_data.items(): setattr(tool, key, value) # If we already computed the new schema, apply it if new_schema is not None: tool.json_schema = new_schema tool.name = new_name if updated_tool_type: tool.tool_type = updated_tool_type # Save the updated tool to the database tool = await tool.update_async(db_session=session, actor=actor) updated_tool = tool.to_pydantic() # Update Modal hash in metadata if needed (inside session context) if needs_modal_deployment: if updated_tool.metadata_ is None: updated_tool.metadata_ = {} updated_tool.metadata_["tool_hash"] = new_hash # Update the metadata in the database (still inside session) tool.metadata_ = updated_tool.metadata_ tool = await tool.update_async(db_session=session, actor=actor) updated_tool = tool.to_pydantic() # Deploy Modal app outside of session (it creates its own sessions) if needs_modal_deployment: logger.info(f"Deploying Modal app for tool {updated_tool.id} with new hash: {new_hash}") await self.create_or_update_modal_app(updated_tool, actor) # Update embedding in Turbopuffer if enabled (delete old, insert new) from letta.helpers.tpuf_client import should_use_tpuf_for_tools if should_use_tpuf_for_tools(): async def update_tool_embedding(): try: from letta.helpers.tpuf_client import TurbopufferClient tpuf_client = TurbopufferClient() # Delete old and re-insert (simpler than update) await tpuf_client.delete_tools(actor.organization_id, [updated_tool.id]) await tpuf_client.insert_tools([updated_tool], actor.organization_id, actor) logger.info(f"Successfully updated tool {updated_tool.id} in Turbopuffer") except Exception as e: logger.error(f"Failed to update tool {updated_tool.id} in Turbopuffer: {e}") fire_and_forget( update_tool_embedding(), task_name=f"update_tool_embedding_{updated_tool.id}", ) return updated_tool @enforce_types @trace_method # @raise_on_invalid_id This is commented out bc it's called by _list_tools_async, when it encounters malformed tools (i.e. if id is invalid will fail validation on deletion) async def delete_tool_by_id_async(self, tool_id: str, actor: PydanticUser) -> None: """Delete a tool by its ID.""" async with db_registry.async_session() as session: try: tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor) # Try to convert to Pydantic to check for Modal cleanup # If this fails (corrupted tool), skip Modal cleanup and just delete try: tool_pydantic = tool.to_pydantic() # Check if tool had Modal deployment and delete it tool_requests_modal = tool_pydantic.metadata_ and tool_pydantic.metadata_.get("sandbox") == "modal" modal_configured = tool_settings.modal_sandbox_enabled if tool_pydantic.tool_type == ToolType.CUSTOM and tool_requests_modal and modal_configured: try: await self.delete_modal_app(tool_pydantic, actor) except Exception as e: logger.warning(f"Failed to delete Modal app for tool {tool_pydantic.name}: {e}") # Continue with tool deletion even if Modal cleanup fails except (ValueError, ValidationError) as e: # Tool is corrupted and can't be converted to Pydantic # Skip Modal cleanup and just delete the tool from database logger.warning(f"Skipping Modal cleanup for corrupted tool {tool_id}: {e}") await tool.hard_delete_async(db_session=session, actor=actor) # Delete from Turbopuffer if enabled from letta.helpers.tpuf_client import should_use_tpuf_for_tools if should_use_tpuf_for_tools(): try: from letta.helpers.tpuf_client import TurbopufferClient tpuf_client = TurbopufferClient() await tpuf_client.delete_tools(actor.organization_id, [tool_id]) logger.info(f"Successfully deleted tool {tool_id} from Turbopuffer") except Exception as e: logger.warning(f"Failed to delete tool {tool_id} from Turbopuffer: {e}") except NoResultFound: raise ValueError(f"Tool with id {tool_id} not found.") @enforce_types @trace_method async def upsert_base_tools_async( self, actor: PydanticUser, allowed_types: Optional[Set[ToolType]] = None, ) -> List[PydanticTool]: """Add default tools defined in the various function_sets modules, optionally filtered by ToolType. Optimized bulk implementation using single database session and batch operations. """ functions_to_schema = {} for module_name in LETTA_TOOL_MODULE_NAMES: try: module = importlib.import_module(module_name) functions_to_schema.update(load_function_set(module)) except ValueError as e: logger.warning(f"Error loading function set '{module_name}': {e}") except Exception as e: raise e # prepare tool data for bulk operations tool_data_list = [] for name, schema in functions_to_schema.items(): if name not in LETTA_TOOL_SET: continue if name in BASE_TOOLS: tool_type = ToolType.LETTA_CORE elif name in BASE_MEMORY_TOOLS: tool_type = ToolType.LETTA_MEMORY_CORE elif name in BASE_SLEEPTIME_TOOLS: tool_type = ToolType.LETTA_SLEEPTIME_CORE elif name in calculate_multi_agent_tools(): tool_type = ToolType.LETTA_MULTI_AGENT_CORE elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS: tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE elif name in BUILTIN_TOOLS: tool_type = ToolType.LETTA_BUILTIN elif name in FILES_TOOLS: tool_type = ToolType.LETTA_FILES_CORE else: logger.warning(f"Tool name {name} is not in any known base tool set, skipping") continue if allowed_types is not None and tool_type not in allowed_types: continue # create pydantic tool for validation and conversion parallel_safe = name in LETTA_PARALLEL_SAFE_TOOLS pydantic_tool = PydanticTool( name=name, tags=[tool_type.value], source_type="python", tool_type=tool_type, return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT, enable_parallel_execution=parallel_safe, ) # auto-generate description if not provided if pydantic_tool.description is None: pydantic_tool.description = pydantic_tool.json_schema.get("description", None) tool_data_list.append(pydantic_tool) if not tool_data_list: return [] if settings.letta_pg_uri_no_default: async with db_registry.async_session() as session: return await self._bulk_upsert_postgresql(session, tool_data_list, actor) else: return await self._upsert_tools_individually(tool_data_list, actor) @trace_method async def _bulk_upsert_postgresql( self, session, tool_data_list: List[PydanticTool], actor: PydanticUser, override_existing_tools: bool = True ) -> List[PydanticTool]: """hyper-optimized postgresql bulk upsert using on_conflict_do_update or on_conflict_do_nothing.""" from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert # prepare data for bulk insert table = ToolModel.__table__ valid_columns = {col.name for col in table.columns} insert_data = [] for tool in tool_data_list: tool_dict = tool.model_dump(to_orm=True) # set created/updated by fields if actor: tool_dict["_created_by_id"] = actor.id tool_dict["_last_updated_by_id"] = actor.id tool_dict["organization_id"] = actor.organization_id # filter to only include columns that exist in the table filtered_dict = {k: v for k, v in tool_dict.items() if k in valid_columns} insert_data.append(filtered_dict) # use postgresql's native bulk upsert stmt = insert(table).values(insert_data) if override_existing_tools: # on conflict, update all columns except id, created_at, and _created_by_id excluded = stmt.excluded update_dict = {} for col in table.columns: if col.name not in ("id", "created_at", "_created_by_id"): if col.name == "updated_at": update_dict[col.name] = func.now() else: update_dict[col.name] = excluded[col.name] upsert_stmt = stmt.on_conflict_do_update(index_elements=["name", "organization_id"], set_=update_dict) else: # on conflict, do nothing (skip existing tools) upsert_stmt = stmt.on_conflict_do_nothing(index_elements=["name", "organization_id"]) await session.execute(upsert_stmt) await session.commit() # fetch results (includes both inserted and skipped tools) tool_names = [tool.name for tool in tool_data_list] result_query = select(ToolModel).where(ToolModel.name.in_(tool_names), ToolModel.organization_id == actor.organization_id) result = await session.execute(result_query) return [tool.to_pydantic() for tool in result.scalars()] @trace_method async def _upsert_tools_individually( self, tool_data_list: List[PydanticTool], actor: PydanticUser, override_existing_tools: bool = True ) -> List[PydanticTool]: """fallback to individual upserts for sqlite (original approach).""" tools = [] for tool in tool_data_list: if override_existing_tools: # update existing tools if they exist upserted_tool = await self.create_or_update_tool_async(tool, actor) tools.append(upserted_tool) else: # skip existing tools, only create new ones existing_tool_id = await self.get_tool_id_by_name_async(tool_name=tool.name, actor=actor) if existing_tool_id: # tool exists, fetch and return it without updating existing_tool = await self.get_tool_by_id_async(existing_tool_id, actor=actor) tools.append(existing_tool) else: # tool doesn't exist, create it created_tool = await self.create_tool_async(tool, actor=actor) tools.append(created_tool) return tools @trace_method async def create_or_update_modal_app(self, tool: PydanticTool, actor: PydanticUser): """Create a Modal app with the tool function registered""" import time import modal from letta.services.sandbox_config_manager import SandboxConfigManager # Load sandbox env vars to bake them into the Modal secrets sandbox_env_vars = {} try: sandbox_config_manager = SandboxConfigManager() sandbox_config = await sandbox_config_manager.get_or_create_default_sandbox_config_async( sandbox_type=SandboxType.MODAL, actor=actor ) if sandbox_config: sandbox_env_vars = await sandbox_config_manager.get_sandbox_env_vars_as_dict_async( sandbox_config_id=sandbox_config.id, actor=actor, limit=None ) logger.info(f"Loaded {len(sandbox_env_vars)} sandbox env vars for Modal app {tool.id}") except Exception as e: logger.warning(f"Could not load sandbox env vars for Modal app {tool.id}: {e}") # Create the Modal app using the global function with sandbox env vars modal_app = modal_tool_wrapper(tool, actor, sandbox_env_vars) # Deploy the app first with modal.enable_output(show_progress=False): try: deploy = modal_app.deploy() except Exception as e: raise LettaInvalidArgumentError(f"Failed to deploy tool {tool.id} with name {tool.name} to Modal: {e}") # After deployment, look up the function to configure autoscaler try: func = modal.Function.from_name(modal_app.name, MODAL_DEFAULT_TOOL_NAME) func.update_autoscaler(scaledown_window=2) # drain inactive old containers time.sleep(5) func.update_autoscaler(scaledown_window=60) except Exception as e: logger.warning(f"Failed to configure autoscaler for Modal function {modal_app.name}: {e}") return deploy async def delete_modal_app(self, tool: PydanticTool, actor: PydanticUser): """Delete a Modal app deployment for the tool""" try: # Generate the app name for this tool modal_app_name = generate_modal_function_name(tool.id, actor.organization_id) # Try to delete the app # TODO: we need to soft delete, and then potentially stop via the CLI, no programmatic way to delete currently # try: # app = modal.App.from_name(modal_app_name) # await app.delete_async() # logger.info(f"Deleted Modal app {modal_app_name} for tool {tool.name}") # except modal.exception.NotFoundError: # logger.info(f"Modal app {modal_app_name} not found, may have been already deleted") # except Exception as e: # logger.warning(f"Failed to delete Modal app {modal_app_name}: {e}") except Exception as e: logger.error(f"Error during Modal app deletion for tool {tool.name}: {e}") raise async def _embed_tool_background( self, tool: PydanticTool, actor: PydanticUser, ) -> None: """Background task to embed a tool in Turbopuffer. Args: tool: The tool to embed actor: User performing the action """ try: from letta.helpers.tpuf_client import TurbopufferClient tpuf_client = TurbopufferClient() await tpuf_client.insert_tools( tools=[tool], organization_id=actor.organization_id, actor=actor, ) logger.info(f"Successfully embedded tool {tool.id} in Turbopuffer") except Exception as e: logger.error(f"Failed to embed tool {tool.id} in Turbopuffer: {e}") @enforce_types @trace_method async def search_tools_async( self, actor: PydanticUser, query_text: Optional[str] = None, search_mode: str = "hybrid", tool_types: Optional[List[str]] = None, tags: Optional[List[str]] = None, limit: int = 50, ) -> List[tuple[PydanticTool, dict]]: """ Search tools using Turbopuffer semantic search. Args: actor: User performing the search query_text: Text query for semantic search search_mode: "vector", "fts", or "hybrid" (default: "hybrid") tool_types: Optional list of tool types to filter by tags: Optional list of tags to filter by limit: Maximum number of results to return Returns: List of (tool, metadata) tuples where metadata contains search scores Raises: ValueError: If Turbopuffer is not enabled for tools """ from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_tools if not should_use_tpuf_for_tools(): raise ValueError("Tool semantic search requires tool embedding to be enabled (embed_tools=True).") tpuf_client = TurbopufferClient() results = await tpuf_client.query_tools( organization_id=actor.organization_id, actor=actor, query_text=query_text, search_mode=search_mode, top_k=limit, tool_types=tool_types, tags=tags, ) if not results: return [] # Fetch full tool objects from database tool_ids = [tool_dict["id"] for tool_dict, _, _ in results] tools = [] for tool_id in tool_ids: try: tool = await self.get_tool_by_id_async(tool_id, actor=actor) tools.append(tool) except Exception: pass # Tool may have been deleted tool_map = {tool.id: tool for tool in tools} # Build result list preserving order and including metadata result_list = [] for tool_dict, _, metadata in results: tool_id = tool_dict["id"] if tool_id in tool_map: result_list.append((tool_map[tool_id], metadata)) return result_list