diff --git a/letta/constants.py b/letta/constants.py index a22ad952..4b3549a5 100644 --- a/letta/constants.py +++ b/letta/constants.py @@ -142,7 +142,7 @@ MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX = re.compile( ) # Built in tools -BUILTIN_TOOLS = ["run_code", "web_search", "fetch_webpage"] +BUILTIN_TOOLS = ["run_code", "run_code_with_tools", "web_search", "fetch_webpage"] # Built in tools FILES_TOOLS = ["open_files", "grep_files", "semantic_search_files"] diff --git a/letta/functions/function_sets/builtin.py b/letta/functions/function_sets/builtin.py index a49f0661..e067988d 100644 --- a/letta/functions/function_sets/builtin.py +++ b/letta/functions/function_sets/builtin.py @@ -15,6 +15,18 @@ def run_code(code: str, language: Literal["python", "js", "ts", "r", "java"]) -> raise NotImplementedError("This is only available on the latest agent architecture. Please contact the Letta team.") +def run_code_with_tools(code: str) -> str: + """ + Run code with access to the tools of the agent. Only support python. You can directly invoke the tools of the agent in the code. + Args: + code (str): The python code to run. + Returns: + str: The output of the code, the stdout, the stderr, and error traces (if any). + """ + + raise NotImplementedError("This is only available on the latest agent architecture. Please contact the Letta team.") + + async def web_search( query: str, num_results: int = 10, diff --git a/letta/services/tool_executor/builtin_tool_executor.py b/letta/services/tool_executor/builtin_tool_executor.py index c5033fa4..97029316 100644 --- a/letta/services/tool_executor/builtin_tool_executor.py +++ b/letta/services/tool_executor/builtin_tool_executor.py @@ -29,7 +29,12 @@ class LettaBuiltinToolExecutor(ToolExecutor): sandbox_config: Optional[SandboxConfig] = None, sandbox_env_vars: Optional[Dict[str, Any]] = None, ) -> ToolExecutionResult: - function_map = {"run_code": self.run_code, "web_search": self.web_search, "fetch_webpage": self.fetch_webpage} + function_map = { + "run_code": self.run_code, + "run_code_with_tools": self.run_code_with_tools, + "web_search": self.web_search, + "fetch_webpage": self.fetch_webpage, + } if function_name not in function_map: raise ValueError(f"Unknown function: {function_name}") @@ -44,17 +49,117 @@ class LettaBuiltinToolExecutor(ToolExecutor): agent_state=agent_state, ) + async def run_code_with_tools(self, agent_state: "AgentState", code: str) -> ToolExecutionResult: + from e2b_code_interpreter import AsyncSandbox + + from letta.utils import get_friendly_error_msg + + if tool_settings.e2b_api_key is None: + raise ValueError("E2B_API_KEY is not set") + + env = {"LETTA_AGENT_ID": agent_state.id} + env.update(agent_state.get_agent_env_vars_as_dict()) + + # Create the sandbox, using template if configured (similar to tool_execution_sandbox.py) + if tool_settings.e2b_sandbox_template_id: + sbx = await AsyncSandbox.create(tool_settings.e2b_sandbox_template_id, api_key=tool_settings.e2b_api_key, envs=env) + else: + sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key, envs=env) + + tool_source_code = "" + lines = [] + + # initialize the letta client + lines.extend( + [ + "# Initialize Letta client for tool execution", + "import os", + "from letta_client import Letta", + "client = None", + "if os.getenv('LETTA_API_KEY'):", + " # Check letta_client version to use correct parameter name", + " from packaging import version as pkg_version", + " import letta_client as lc_module", + " lc_version = pkg_version.parse(lc_module.__version__)", + " if lc_version < pkg_version.parse('1.0.0'):", + " client = Letta(", + " token=os.getenv('LETTA_API_KEY')", + " )", + " else:", + " client = Letta(", + " api_key=os.getenv('LETTA_API_KEY')", + " )", + ] + ) + tool_source_code = "\n".join(lines) + "\n" + # Inject source code from agent's tools to enable programmatic tool calling + # This allows Claude to compose tools in a single code execution, e.g.: + # run_code("result = add(multiply(4, 5), 6)") + from letta.schemas.enums import ToolType + + if agent_state and agent_state.tools: + for tool in agent_state.tools: + if tool.tool_type == ToolType.CUSTOM and tool.source_code: + # simply append the source code of the tool + # TODO: can get rid of this option + tool_source_code += tool.source_code + "\n\n" + else: + # invoke the tool through the client + # raises an error if LETTA_API_KEY or other envs not set + tool_lines = [ + f"def {tool.name}(**kwargs):", + " if not os.getenv('LETTA_API_KEY'):", + " raise ValueError('LETTA_API_KEY is not set')", + " if not os.getenv('LETTA_AGENT_ID'):", + " raise ValueError('LETTA_AGENT_ID is not set')", + f" result = client.agents.tools.run(agent_id=os.getenv('LETTA_AGENT_ID'), tool_name='{tool.name}', args=kwargs)", + " if result.status == 'success':", + " return result.func_return", + " else:", + " raise ValueError(result.stderr)", + ] + tool_source_code += "\n".join(tool_lines) + "\n\n" + + params = {"code": tool_source_code + code} + + execution = await sbx.run_code(**params) + + # Parse results similar to e2b_sandbox.py + if execution.results: + func_return = execution.results[0].text if hasattr(execution.results[0], "text") else str(execution.results[0]) + elif execution.error: + func_return = get_friendly_error_msg( + function_name="run_code_with_tools", exception_name=execution.error.name, exception_message=execution.error.value + ) + execution.logs.stderr.append(execution.error.traceback) + else: + func_return = None + + return json.dumps( + { + "status": "error" if execution.error else "success", + "func_return": func_return, + "stdout": execution.logs.stdout, + "stderr": execution.logs.stderr, + }, + ensure_ascii=False, + ) + async def run_code(self, agent_state: "AgentState", code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str: from e2b_code_interpreter import AsyncSandbox if tool_settings.e2b_api_key is None: raise ValueError("E2B_API_KEY is not set") - sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key) + # Create the sandbox, using template if configured (similar to tool_execution_sandbox.py) + if tool_settings.e2b_sandbox_template_id: + sbx = await AsyncSandbox.create(tool_settings.e2b_sandbox_template_id, api_key=tool_settings.e2b_api_key) + else: + sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key) # Inject source code from agent's tools to enable programmatic tool calling # This allows Claude to compose tools in a single code execution, e.g.: - # run_code("result = add(multiply(4, 5), 6)") + # run_code_with_tools("result = add(multiply(4, 5), 6)") if language == "python" and agent_state and agent_state.tools: tool_source_code = "" for tool in agent_state.tools: diff --git a/pyproject.toml b/pyproject.toml index 24d1cfe6..ad35f162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "llama-index>=0.12.2", "llama-index-embeddings-openai>=0.3.1", "anthropic>=0.75.0", - "letta-client>=1.1.2", + "letta-client>=1.3.1", "openai>=1.99.9", "opentelemetry-api==1.30.0", "opentelemetry-sdk==1.30.0", diff --git a/uv.lock b/uv.lock index fb17b0ca..c9667910 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -2522,7 +2522,7 @@ requires-dist = [ { name = "langchain", marker = "extra == 'external-tools'", specifier = ">=0.3.7" }, { name = "langchain-community", marker = "extra == 'desktop'", specifier = ">=0.3.7" }, { name = "langchain-community", marker = "extra == 'external-tools'", specifier = ">=0.3.7" }, - { name = "letta-client", specifier = ">=1.1.2" }, + { name = "letta-client", specifier = ">=1.3.1" }, { name = "llama-index", specifier = ">=0.12.2" }, { name = "llama-index-embeddings-openai", specifier = ">=0.3.1" }, { name = "locust", marker = "extra == 'desktop'", specifier = ">=2.31.5" }, @@ -2599,7 +2599,7 @@ provides-extras = ["postgres", "redis", "pinecone", "sqlite", "experimental", "s [[package]] name = "letta-client" -version = "1.1.2" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -2609,9 +2609,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/8c/b31ad4bc3fad1c563b4467762b67f7eca7bc65cb2c0c2ca237b6b6a485ae/letta_client-1.1.2.tar.gz", hash = "sha256:2687b3aebc31401db4f273719db459b2a7a2a527779b87d56c53d2bdf664e4d3", size = 231825, upload-time = "2025-11-21T03:06:06.737Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/f3/bca794dc7a7a735c5e95aabd406fbbf9e4296c9eccf5f7cb0bcd357e67d1/letta_client-1.3.1.tar.gz", hash = "sha256:28eff58052a4bf829f8964fca46c858509b2e6b49c3b1cb49d4bac4a91b1101f", size = 238563, upload-time = "2025-11-26T20:55:30.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/22/ec950b367a3cc5e2c8ae426e84c13a2d824ea4e337dbd7b94300a1633929/letta_client-1.1.2-py3-none-any.whl", hash = "sha256:86d9c6f2f9e773965f2107898584e8650f3843f938604da9fd1dfe6a462af533", size = 357516, upload-time = "2025-11-21T03:06:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/21/28/c21f067cc7def89ee60461fc14e7bc5401f37f67664921914fdc3b4a4a91/letta_client-1.3.1-py3-none-any.whl", hash = "sha256:61a7576877c6a882c7e31eebc59a290c42bed4e6a5ba521f244ced9194cacdfb", size = 369529, upload-time = "2025-11-26T20:55:29.106Z" }, ] [[package]]