* feat: add TypeScript tool support for E2B sandbox execution This change implements TypeScript tool support using the same E2B path as Python tools: - Add TypeScript execution script generator (typescript_generator.py) - Modify E2B sandbox to detect TypeScript tools and use language='ts' - Add npm package installation for TypeScript tool dependencies - Add validation requiring json_schema for TypeScript tools - Add comprehensive integration tests for TypeScript tools TypeScript tools: - Require explicit json_schema (no docstring parsing) - Use JSON serialization instead of pickle for results - Support async functions with top-level await - Support npm package dependencies via npm_requirements field Closes #8793 Co-authored-by: Sarah Wooders <sarahwooders@users.noreply.github.com> * fix: disable AgentState for TypeScript tools & add letta-client injection Based on Sarah's feedback: 1. AgentState is a legacy Python-only feature, disabled for TS tools 2. Added @letta-ai/letta-client npm package injection for TypeScript (similar to letta_client for Python) Changes: - base.py: Explicitly set inject_agent_state=False for TypeScript tools - typescript_generator.py: Inject LettaClient initialization code - e2b_sandbox.py: Auto-install @letta-ai/letta-client for TS tools - Added tests verifying both behaviors 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Sarah Wooders <sarahwooders@users.noreply.github.com> Co-Authored-By: Letta <noreply@letta.com> * Update core-integration-tests.yml * fix: convert TypeScript test fixtures to async The OrganizationManager and UserManager no longer have sync methods, only async variants. Updated all fixtures to use: - create_organization_async - create_actor_async - create_or_update_tool_async 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * fix: skip Python AST parsing for TypeScript tools in sandbox base The _init_async method was calling parse_function_arguments (which uses Python's ast.parse) before checking if the tool was TypeScript, causing SyntaxError when running TypeScript tools. Moved the is_typescript_tool() check to happen first, skipping Python AST parsing entirely for TypeScript tools. 🤖 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta <noreply@letta.com> * letta_agent_id * skip ast parsing for s * add tool execution test --------- Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Sarah Wooders <sarahwooders@users.noreply.github.com> Co-authored-by: Letta <noreply@letta.com> Co-authored-by: Kian Jones <kian@letta.com>
245 lines
13 KiB
Python
245 lines
13 KiB
Python
from typing import Any, Dict, List, Literal, Optional
|
|
|
|
from pydantic import ConfigDict, Field, model_validator
|
|
|
|
from letta.constants import (
|
|
FUNCTION_RETURN_CHAR_LIMIT,
|
|
LETTA_BUILTIN_TOOL_MODULE_NAME,
|
|
LETTA_CORE_TOOL_MODULE_NAME,
|
|
LETTA_FILES_TOOL_MODULE_NAME,
|
|
LETTA_MULTI_AGENT_TOOL_MODULE_NAME,
|
|
LETTA_VOICE_TOOL_MODULE_NAME,
|
|
MCP_TOOL_TAG_NAME_PREFIX,
|
|
)
|
|
from letta.schemas.enums import PrimitiveType
|
|
|
|
# MCP Tool metadata constants for schema health status
|
|
MCP_TOOL_METADATA_SCHEMA_STATUS = f"{MCP_TOOL_TAG_NAME_PREFIX}:SCHEMA_STATUS"
|
|
MCP_TOOL_METADATA_SCHEMA_WARNINGS = f"{MCP_TOOL_TAG_NAME_PREFIX}:SCHEMA_WARNINGS"
|
|
from letta.functions.functions import get_json_schema_from_module
|
|
from letta.functions.mcp_client.types import MCPTool
|
|
from letta.functions.schema_generator import generate_tool_schema_for_mcp
|
|
from letta.log import get_logger
|
|
from letta.schemas.enums import ToolSourceType, ToolType
|
|
from letta.schemas.letta_base import LettaBase
|
|
from letta.schemas.npm_requirement import NpmRequirement
|
|
from letta.schemas.pip_requirement import PipRequirement
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class BaseTool(LettaBase):
|
|
__id_prefix__ = PrimitiveType.TOOL.value
|
|
|
|
|
|
class Tool(BaseTool):
|
|
"""Representation of a tool, which is a function that can be called by the agent."""
|
|
|
|
id: str = BaseTool.generate_id_field()
|
|
tool_type: ToolType = Field(ToolType.CUSTOM, description="The type of the tool.")
|
|
description: Optional[str] = Field(None, description="The description of the tool.")
|
|
source_type: Optional[str] = Field(None, description="The type of the source code.")
|
|
name: Optional[str] = Field(None, description="The name of the function.")
|
|
tags: List[str] = Field([], description="Metadata tags.")
|
|
|
|
# code
|
|
source_code: Optional[str] = Field(None, description="The source code of the function.")
|
|
json_schema: Optional[Dict] = Field(None, description="The JSON schema of the function.")
|
|
args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
|
|
|
|
# tool configuration
|
|
return_char_limit: int = Field(
|
|
FUNCTION_RETURN_CHAR_LIMIT,
|
|
description="The maximum number of characters in the response.",
|
|
ge=1,
|
|
le=1_000_000,
|
|
)
|
|
pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
|
|
npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
|
|
default_requires_approval: Optional[bool] = Field(
|
|
None, description="Default value for whether or not executing this tool requires approval."
|
|
)
|
|
enable_parallel_execution: Optional[bool] = Field(
|
|
False, description="If set to True, then this tool will potentially be executed concurrently with other tools. Default False."
|
|
)
|
|
|
|
# metadata fields
|
|
created_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
|
|
last_updated_by_id: Optional[str] = Field(None, description="The id of the user that made this Tool.")
|
|
metadata_: Optional[Dict[str, Any]] = Field(default_factory=dict, description="A dictionary of additional metadata for the tool.")
|
|
|
|
# project scoping
|
|
project_id: Optional[str] = Field(None, description="The project id of the tool.")
|
|
|
|
@model_validator(mode="after")
|
|
def refresh_source_code_and_json_schema(self):
|
|
"""
|
|
Refresh name, description, source_code, and json_schema.
|
|
|
|
Note: Schema generation for custom tools is now handled at creation/update time in ToolManager.
|
|
This method only handles built-in Letta tools.
|
|
"""
|
|
if self.tool_type == ToolType.CUSTOM:
|
|
# Custom tools should already have their schema set during creation/update
|
|
# No schema generation happens here anymore
|
|
if not self.json_schema:
|
|
logger.warning(
|
|
"Custom tool with id=%s name=%s is missing json_schema. Schema should be set during creation/update.",
|
|
self.id,
|
|
self.name,
|
|
)
|
|
elif self.tool_type in {ToolType.LETTA_CORE, ToolType.LETTA_MEMORY_CORE, ToolType.LETTA_SLEEPTIME_CORE}:
|
|
# If it's letta core tool, we generate the json_schema on the fly here
|
|
self.json_schema = get_json_schema_from_module(module_name=LETTA_CORE_TOOL_MODULE_NAME, function_name=self.name)
|
|
elif self.tool_type in {ToolType.LETTA_MULTI_AGENT_CORE}:
|
|
# If it's letta multi-agent tool, we also generate the json_schema on the fly here
|
|
self.json_schema = get_json_schema_from_module(module_name=LETTA_MULTI_AGENT_TOOL_MODULE_NAME, function_name=self.name)
|
|
elif self.tool_type in {ToolType.LETTA_VOICE_SLEEPTIME_CORE}:
|
|
# If it's letta voice tool, we generate the json_schema on the fly here
|
|
self.json_schema = get_json_schema_from_module(module_name=LETTA_VOICE_TOOL_MODULE_NAME, function_name=self.name)
|
|
elif self.tool_type in {ToolType.LETTA_BUILTIN}:
|
|
# If it's letta voice tool, we generate the json_schema on the fly here
|
|
self.json_schema = get_json_schema_from_module(module_name=LETTA_BUILTIN_TOOL_MODULE_NAME, function_name=self.name)
|
|
elif self.tool_type in {ToolType.LETTA_FILES_CORE}:
|
|
# If it's letta files tool, we generate the json_schema on the fly here
|
|
self.json_schema = get_json_schema_from_module(module_name=LETTA_FILES_TOOL_MODULE_NAME, function_name=self.name)
|
|
|
|
return self
|
|
|
|
|
|
class ToolCreate(LettaBase):
|
|
description: Optional[str] = Field(None, description="The description of the tool.")
|
|
tags: Optional[List[str]] = Field(None, description="Metadata tags.")
|
|
source_code: str = Field(..., description="The source code of the function.")
|
|
source_type: str = Field("python", description="The source type of the function.")
|
|
json_schema: Optional[Dict] = Field(
|
|
None, description="The JSON schema of the function (auto-generated from source_code if not provided)"
|
|
)
|
|
args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
|
|
return_char_limit: int = Field(
|
|
FUNCTION_RETURN_CHAR_LIMIT,
|
|
description="The maximum number of characters in the response.",
|
|
ge=1,
|
|
le=1_000_000,
|
|
)
|
|
pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
|
|
npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
|
|
default_requires_approval: Optional[bool] = Field(None, description="Whether or not to require approval before executing this tool.")
|
|
enable_parallel_execution: Optional[bool] = Field(
|
|
False, description="If set to True, then this tool will potentially be executed concurrently with other tools. Default False."
|
|
)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_typescript_requires_schema(self):
|
|
"""
|
|
TypeScript tools require an explicit json_schema since we don't support
|
|
docstring parsing for TypeScript.
|
|
"""
|
|
if self.source_type == "typescript" and not self.json_schema:
|
|
raise ValueError(
|
|
"TypeScript tools require an explicit json_schema parameter. "
|
|
"Unlike Python tools, schema cannot be auto-generated from TypeScript source code."
|
|
)
|
|
return self
|
|
|
|
@classmethod
|
|
def from_mcp(cls, mcp_server_name: str, mcp_tool: MCPTool) -> "ToolCreate":
|
|
from letta.functions.helpers import generate_mcp_tool_wrapper
|
|
|
|
# Pass the MCP tool to the schema generator
|
|
json_schema = generate_tool_schema_for_mcp(mcp_tool=mcp_tool)
|
|
|
|
# Store health status in json_schema metadata if available
|
|
if mcp_tool.health:
|
|
json_schema[MCP_TOOL_METADATA_SCHEMA_STATUS] = mcp_tool.health.status
|
|
json_schema[MCP_TOOL_METADATA_SCHEMA_WARNINGS] = mcp_tool.health.reasons
|
|
|
|
# Return a ToolCreate instance
|
|
description = mcp_tool.description
|
|
source_type = "python"
|
|
tags = [f"{MCP_TOOL_TAG_NAME_PREFIX}:{mcp_server_name}"]
|
|
wrapper_func_name, wrapper_function_str = generate_mcp_tool_wrapper(mcp_tool.name)
|
|
|
|
return cls(
|
|
description=description,
|
|
source_type=source_type,
|
|
tags=tags,
|
|
source_code=wrapper_function_str,
|
|
json_schema=json_schema,
|
|
)
|
|
|
|
def model_dump(self, to_orm: bool = False, **kwargs):
|
|
"""
|
|
Override LettaBase.model_dump to explicitly handle 'tags' being None,
|
|
ensuring that the output includes 'tags' as None (or any current value).
|
|
"""
|
|
data = super().model_dump(**kwargs)
|
|
# TODO: consider making tags itself optional in the ORM
|
|
# Ensure 'tags' is included even when None, but only if tags is in the dict
|
|
# (i.e., don't add tags if exclude_unset=True was used and tags wasn't set)
|
|
if "tags" in data and data["tags"] is None:
|
|
data["tags"] = []
|
|
return data
|
|
|
|
|
|
class ToolUpdate(LettaBase):
|
|
description: Optional[str] = Field(None, description="The description of the tool.")
|
|
tags: Optional[List[str]] = Field(None, description="Metadata tags.")
|
|
source_code: Optional[str] = Field(None, description="The source code of the function.")
|
|
source_type: Optional[str] = Field(None, description="The type of the source code.")
|
|
json_schema: Optional[Dict] = Field(
|
|
None, description="The JSON schema of the function (auto-generated from source_code if not provided)"
|
|
)
|
|
args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
|
|
return_char_limit: Optional[int] = Field(
|
|
None,
|
|
description="The maximum number of characters in the response.",
|
|
ge=1,
|
|
le=1_000_000,
|
|
)
|
|
pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
|
|
npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
|
|
metadata_: Optional[Dict[str, Any]] = Field(None, description="A dictionary of additional metadata for the tool.")
|
|
default_requires_approval: Optional[bool] = Field(None, description="Whether or not to require approval before executing this tool.")
|
|
enable_parallel_execution: Optional[bool] = Field(
|
|
False, description="If set to True, then this tool will potentially be executed concurrently with other tools. Default False."
|
|
)
|
|
# name: Optional[str] = Field(None, description="The name of the tool (must match the JSON schema name and source code function name).")
|
|
|
|
model_config = ConfigDict(extra="ignore") # Allows extra fields without validation errors
|
|
# TODO: Remove this, and clean usage of ToolUpdate everywhere else
|
|
|
|
|
|
class ToolRunFromSource(LettaBase):
|
|
source_code: str = Field(..., description="The source code of the function.")
|
|
args: Dict[str, Any] = Field(..., description="The arguments to pass to the tool.")
|
|
env_vars: Dict[str, str] = Field(None, description="The environment variables to pass to the tool.")
|
|
name: Optional[str] = Field(None, description="The name of the tool to run.")
|
|
source_type: Optional[str] = Field(None, description="The type of the source code.")
|
|
args_json_schema: Optional[Dict] = Field(None, description="The args JSON schema of the function.")
|
|
json_schema: Optional[Dict] = Field(
|
|
None, description="The JSON schema of the function (auto-generated from source_code if not provided)"
|
|
)
|
|
pip_requirements: list[PipRequirement] | None = Field(None, description="Optional list of pip packages required by this tool.")
|
|
npm_requirements: list[NpmRequirement] | None = Field(None, description="Optional list of npm packages required by this tool.")
|
|
|
|
|
|
class ToolSearchRequest(LettaBase):
|
|
"""Request model for searching tools using semantic search."""
|
|
|
|
query: Optional[str] = Field(None, description="Text query for semantic search.")
|
|
search_mode: Literal["vector", "fts", "hybrid"] = Field("hybrid", description="Search mode: vector, fts, or hybrid.")
|
|
tool_types: Optional[List[str]] = Field(None, description="Filter by tool types (e.g., 'custom', 'letta_core').")
|
|
tags: Optional[List[str]] = Field(None, description="Filter by tags (match any).")
|
|
limit: int = Field(50, description="Maximum number of results to return.", ge=1, le=100)
|
|
|
|
|
|
class ToolSearchResult(LettaBase):
|
|
"""Result from a tool search operation."""
|
|
|
|
tool: Tool = Field(..., description="The matched tool.")
|
|
embedded_text: Optional[str] = Field(None, description="The embedded text content used for matching.")
|
|
fts_rank: Optional[int] = Field(None, description="Full-text search rank position.")
|
|
vector_rank: Optional[int] = Field(None, description="Vector search rank position.")
|
|
combined_score: float = Field(..., description="Combined relevance score (RRF for hybrid mode).")
|