letta-nightly 0.7.20.dev20250521104258__py3-none-any.whl → 0.7.21.dev20250522104246__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/__init__.py +1 -1
- letta/agent.py +290 -3
- letta/agents/base_agent.py +0 -55
- letta/agents/helpers.py +5 -0
- letta/agents/letta_agent.py +314 -64
- letta/agents/letta_agent_batch.py +102 -55
- letta/agents/voice_agent.py +5 -5
- letta/client/client.py +9 -18
- letta/constants.py +55 -1
- letta/functions/function_sets/builtin.py +27 -0
- letta/groups/sleeptime_multi_agent_v2.py +1 -1
- letta/interfaces/anthropic_streaming_interface.py +10 -1
- letta/interfaces/openai_streaming_interface.py +9 -2
- letta/llm_api/anthropic.py +21 -2
- letta/llm_api/anthropic_client.py +33 -6
- letta/llm_api/google_ai_client.py +136 -423
- letta/llm_api/google_vertex_client.py +173 -22
- letta/llm_api/llm_api_tools.py +27 -0
- letta/llm_api/llm_client.py +1 -1
- letta/llm_api/llm_client_base.py +32 -21
- letta/llm_api/openai.py +57 -0
- letta/llm_api/openai_client.py +7 -11
- letta/memory.py +0 -1
- letta/orm/__init__.py +1 -0
- letta/orm/enums.py +1 -0
- letta/orm/provider_trace.py +26 -0
- letta/orm/step.py +1 -0
- letta/schemas/provider_trace.py +43 -0
- letta/schemas/providers.py +210 -65
- letta/schemas/step.py +1 -0
- letta/schemas/tool.py +4 -0
- letta/server/db.py +37 -19
- letta/server/rest_api/routers/v1/__init__.py +2 -0
- letta/server/rest_api/routers/v1/agents.py +57 -34
- letta/server/rest_api/routers/v1/blocks.py +3 -3
- letta/server/rest_api/routers/v1/identities.py +24 -26
- letta/server/rest_api/routers/v1/jobs.py +3 -3
- letta/server/rest_api/routers/v1/llms.py +13 -8
- letta/server/rest_api/routers/v1/sandbox_configs.py +6 -6
- letta/server/rest_api/routers/v1/tags.py +3 -3
- letta/server/rest_api/routers/v1/telemetry.py +18 -0
- letta/server/rest_api/routers/v1/tools.py +6 -6
- letta/server/rest_api/streaming_response.py +105 -0
- letta/server/rest_api/utils.py +4 -0
- letta/server/server.py +140 -1
- letta/services/agent_manager.py +251 -18
- letta/services/block_manager.py +52 -37
- letta/services/helpers/noop_helper.py +10 -0
- letta/services/identity_manager.py +43 -38
- letta/services/job_manager.py +29 -0
- letta/services/message_manager.py +111 -0
- letta/services/sandbox_config_manager.py +36 -0
- letta/services/step_manager.py +146 -0
- letta/services/telemetry_manager.py +58 -0
- letta/services/tool_executor/tool_execution_manager.py +49 -5
- letta/services/tool_executor/tool_execution_sandbox.py +47 -0
- letta/services/tool_executor/tool_executor.py +236 -7
- letta/services/tool_manager.py +160 -1
- letta/services/tool_sandbox/e2b_sandbox.py +65 -3
- letta/settings.py +10 -2
- letta/tracing.py +5 -5
- {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/METADATA +3 -2
- {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/RECORD +66 -59
- {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/LICENSE +0 -0
- {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/WHEEL +0 -0
- {letta_nightly-0.7.20.dev20250521104258.dist-info → letta_nightly-0.7.21.dev20250522104246.dist-info}/entry_points.txt +0 -0
@@ -1,35 +1,64 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
1
3
|
import math
|
2
4
|
import traceback
|
3
5
|
from abc import ABC, abstractmethod
|
4
|
-
from
|
6
|
+
from textwrap import shorten
|
7
|
+
from typing import Any, Dict, List, Literal, Optional
|
5
8
|
|
6
9
|
from letta.constants import (
|
7
10
|
COMPOSIO_ENTITY_ENV_VAR_KEY,
|
8
11
|
CORE_MEMORY_LINE_NUMBER_WARNING,
|
9
12
|
READ_ONLY_BLOCK_EDIT_ERROR,
|
10
13
|
RETRIEVAL_QUERY_DEFAULT_PAGE_SIZE,
|
14
|
+
WEB_SEARCH_CLIP_CONTENT,
|
15
|
+
WEB_SEARCH_INCLUDE_SCORE,
|
16
|
+
WEB_SEARCH_SEPARATOR,
|
11
17
|
)
|
12
18
|
from letta.functions.ast_parsers import coerce_dict_args_by_annotations, get_function_annotations_from_source
|
13
19
|
from letta.functions.composio_helpers import execute_composio_action_async, generate_composio_action_from_func_name
|
14
20
|
from letta.helpers.composio_helpers import get_composio_api_key
|
15
21
|
from letta.helpers.json_helpers import json_dumps
|
22
|
+
from letta.log import get_logger
|
16
23
|
from letta.schemas.agent import AgentState
|
24
|
+
from letta.schemas.enums import MessageRole
|
25
|
+
from letta.schemas.letta_message import AssistantMessage
|
26
|
+
from letta.schemas.letta_message_content import TextContent
|
27
|
+
from letta.schemas.message import MessageCreate
|
17
28
|
from letta.schemas.sandbox_config import SandboxConfig
|
18
29
|
from letta.schemas.tool import Tool
|
19
30
|
from letta.schemas.tool_execution_result import ToolExecutionResult
|
20
31
|
from letta.schemas.user import User
|
21
32
|
from letta.services.agent_manager import AgentManager
|
33
|
+
from letta.services.block_manager import BlockManager
|
22
34
|
from letta.services.message_manager import MessageManager
|
23
35
|
from letta.services.passage_manager import PassageManager
|
24
36
|
from letta.services.tool_sandbox.e2b_sandbox import AsyncToolSandboxE2B
|
25
37
|
from letta.services.tool_sandbox.local_sandbox import AsyncToolSandboxLocal
|
26
38
|
from letta.settings import tool_settings
|
39
|
+
from letta.tracing import trace_method
|
27
40
|
from letta.utils import get_friendly_error_msg
|
28
41
|
|
42
|
+
logger = get_logger(__name__)
|
43
|
+
|
29
44
|
|
30
45
|
class ToolExecutor(ABC):
|
31
46
|
"""Abstract base class for tool executors."""
|
32
47
|
|
48
|
+
def __init__(
|
49
|
+
self,
|
50
|
+
message_manager: MessageManager,
|
51
|
+
agent_manager: AgentManager,
|
52
|
+
block_manager: BlockManager,
|
53
|
+
passage_manager: PassageManager,
|
54
|
+
actor: User,
|
55
|
+
):
|
56
|
+
self.message_manager = message_manager
|
57
|
+
self.agent_manager = agent_manager
|
58
|
+
self.block_manager = block_manager
|
59
|
+
self.passage_manager = passage_manager
|
60
|
+
self.actor = actor
|
61
|
+
|
33
62
|
@abstractmethod
|
34
63
|
def execute(
|
35
64
|
self,
|
@@ -493,17 +522,113 @@ class LettaCoreToolExecutor(ToolExecutor):
|
|
493
522
|
class LettaMultiAgentToolExecutor(ToolExecutor):
|
494
523
|
"""Executor for LETTA multi-agent core tools."""
|
495
524
|
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
525
|
+
async def execute(
|
526
|
+
self,
|
527
|
+
function_name: str,
|
528
|
+
function_args: dict,
|
529
|
+
agent_state: AgentState,
|
530
|
+
tool: Tool,
|
531
|
+
actor: User,
|
532
|
+
sandbox_config: Optional[SandboxConfig] = None,
|
533
|
+
sandbox_env_vars: Optional[Dict[str, Any]] = None,
|
534
|
+
) -> ToolExecutionResult:
|
535
|
+
function_map = {
|
536
|
+
"send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply,
|
537
|
+
"send_message_to_agent_async": self.send_message_to_agent_async,
|
538
|
+
"send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags,
|
539
|
+
}
|
540
|
+
|
541
|
+
if function_name not in function_map:
|
542
|
+
raise ValueError(f"Unknown function: {function_name}")
|
543
|
+
|
544
|
+
# Execute the appropriate function
|
545
|
+
function_args_copy = function_args.copy() # Make a copy to avoid modifying the original
|
546
|
+
function_response = await function_map[function_name](agent_state, **function_args_copy)
|
547
|
+
return ToolExecutionResult(
|
548
|
+
status="success",
|
549
|
+
func_return=function_response,
|
550
|
+
)
|
551
|
+
|
552
|
+
async def send_message_to_agent_and_wait_for_reply(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
|
553
|
+
augmented_message = (
|
554
|
+
f"[Incoming message from agent with ID '{agent_state.id}' - to reply to this message, "
|
555
|
+
f"make sure to use the 'send_message' at the end, and the system will notify the sender of your response] "
|
556
|
+
f"{message}"
|
557
|
+
)
|
558
|
+
|
559
|
+
return str(await self._process_agent(agent_id=other_agent_id, message=augmented_message))
|
560
|
+
|
561
|
+
async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
|
562
|
+
# 1) Build the prefixed system‐message
|
563
|
+
prefixed = (
|
564
|
+
f"[Incoming message from agent with ID '{agent_state.id}' - "
|
565
|
+
f"to reply to this message, make sure to use the "
|
566
|
+
f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
|
567
|
+
f"{message}"
|
568
|
+
)
|
569
|
+
|
570
|
+
task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
|
571
|
+
|
572
|
+
task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
|
573
|
+
|
574
|
+
return "Successfully sent message"
|
575
|
+
|
576
|
+
async def send_message_to_agents_matching_tags(
|
577
|
+
self, agent_state: AgentState, message: str, match_all: List[str], match_some: List[str]
|
578
|
+
) -> str:
|
579
|
+
# Find matching agents
|
580
|
+
matching_agents = self.agent_manager.list_agents_matching_tags(actor=self.actor, match_all=match_all, match_some=match_some)
|
581
|
+
if not matching_agents:
|
582
|
+
return str([])
|
583
|
+
|
584
|
+
augmented_message = (
|
585
|
+
"[Incoming message from external Letta agent - to reply to this message, "
|
586
|
+
"make sure to use the 'send_message' at the end, and the system will notify "
|
587
|
+
"the sender of your response] "
|
588
|
+
f"{message}"
|
589
|
+
)
|
590
|
+
|
591
|
+
tasks = [
|
592
|
+
asyncio.create_task(self._process_agent(agent_id=agent_state.id, message=augmented_message)) for agent_state in matching_agents
|
593
|
+
]
|
594
|
+
results = await asyncio.gather(*tasks)
|
595
|
+
return str(results)
|
596
|
+
|
597
|
+
async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]:
|
598
|
+
from letta.agents.letta_agent import LettaAgent
|
599
|
+
|
600
|
+
try:
|
601
|
+
letta_agent = LettaAgent(
|
602
|
+
agent_id=agent_id,
|
603
|
+
message_manager=self.message_manager,
|
604
|
+
agent_manager=self.agent_manager,
|
605
|
+
block_manager=self.block_manager,
|
606
|
+
passage_manager=self.passage_manager,
|
607
|
+
actor=self.actor,
|
608
|
+
)
|
609
|
+
|
610
|
+
letta_response = await letta_agent.step([MessageCreate(role=MessageRole.system, content=[TextContent(text=message)])])
|
611
|
+
messages = letta_response.messages
|
612
|
+
|
613
|
+
send_message_content = [message.content for message in messages if isinstance(message, AssistantMessage)]
|
614
|
+
|
615
|
+
return {
|
616
|
+
"agent_id": agent_id,
|
617
|
+
"response": send_message_content if send_message_content else ["<no response>"],
|
618
|
+
}
|
619
|
+
|
620
|
+
except Exception as e:
|
621
|
+
return {
|
622
|
+
"agent_id": agent_id,
|
623
|
+
"error": str(e),
|
624
|
+
"type": type(e).__name__,
|
625
|
+
}
|
502
626
|
|
503
627
|
|
504
628
|
class ExternalComposioToolExecutor(ToolExecutor):
|
505
629
|
"""Executor for external Composio tools."""
|
506
630
|
|
631
|
+
@trace_method
|
507
632
|
async def execute(
|
508
633
|
self,
|
509
634
|
function_name: str,
|
@@ -595,6 +720,7 @@ class ExternalMCPToolExecutor(ToolExecutor):
|
|
595
720
|
class SandboxToolExecutor(ToolExecutor):
|
596
721
|
"""Executor for sandboxed tools."""
|
597
722
|
|
723
|
+
@trace_method
|
598
724
|
async def execute(
|
599
725
|
self,
|
600
726
|
function_name: str,
|
@@ -674,3 +800,106 @@ class SandboxToolExecutor(ToolExecutor):
|
|
674
800
|
func_return=error_message,
|
675
801
|
stderr=[stderr],
|
676
802
|
)
|
803
|
+
|
804
|
+
|
805
|
+
class LettaBuiltinToolExecutor(ToolExecutor):
|
806
|
+
"""Executor for built in Letta tools."""
|
807
|
+
|
808
|
+
@trace_method
|
809
|
+
async def execute(
|
810
|
+
self,
|
811
|
+
function_name: str,
|
812
|
+
function_args: dict,
|
813
|
+
agent_state: AgentState,
|
814
|
+
tool: Tool,
|
815
|
+
actor: User,
|
816
|
+
sandbox_config: Optional[SandboxConfig] = None,
|
817
|
+
sandbox_env_vars: Optional[Dict[str, Any]] = None,
|
818
|
+
) -> ToolExecutionResult:
|
819
|
+
function_map = {"run_code": self.run_code, "web_search": self.web_search}
|
820
|
+
|
821
|
+
if function_name not in function_map:
|
822
|
+
raise ValueError(f"Unknown function: {function_name}")
|
823
|
+
|
824
|
+
# Execute the appropriate function
|
825
|
+
function_args_copy = function_args.copy() # Make a copy to avoid modifying the original
|
826
|
+
function_response = await function_map[function_name](**function_args_copy)
|
827
|
+
|
828
|
+
return ToolExecutionResult(
|
829
|
+
status="success",
|
830
|
+
func_return=function_response,
|
831
|
+
)
|
832
|
+
|
833
|
+
async def run_code(self, code: str, language: Literal["python", "js", "ts", "r", "java"]) -> str:
|
834
|
+
from e2b_code_interpreter import AsyncSandbox
|
835
|
+
|
836
|
+
if tool_settings.e2b_api_key is None:
|
837
|
+
raise ValueError("E2B_API_KEY is not set")
|
838
|
+
|
839
|
+
sbx = await AsyncSandbox.create(api_key=tool_settings.e2b_api_key)
|
840
|
+
params = {"code": code}
|
841
|
+
if language != "python":
|
842
|
+
# Leave empty for python
|
843
|
+
params["language"] = language
|
844
|
+
|
845
|
+
res = self._llm_friendly_result(await sbx.run_code(**params))
|
846
|
+
return json.dumps(res, ensure_ascii=False)
|
847
|
+
|
848
|
+
def _llm_friendly_result(self, res):
|
849
|
+
out = {
|
850
|
+
"results": [r.text if hasattr(r, "text") else str(r) for r in res.results],
|
851
|
+
"logs": {
|
852
|
+
"stdout": getattr(res.logs, "stdout", []),
|
853
|
+
"stderr": getattr(res.logs, "stderr", []),
|
854
|
+
},
|
855
|
+
}
|
856
|
+
err = getattr(res, "error", None)
|
857
|
+
if err is not None:
|
858
|
+
out["error"] = err
|
859
|
+
return out
|
860
|
+
|
861
|
+
async def web_search(agent_state: "AgentState", query: str) -> str:
|
862
|
+
"""
|
863
|
+
Search the web for information.
|
864
|
+
Args:
|
865
|
+
query (str): The query to search the web for.
|
866
|
+
Returns:
|
867
|
+
str: The search results.
|
868
|
+
"""
|
869
|
+
|
870
|
+
try:
|
871
|
+
from tavily import AsyncTavilyClient
|
872
|
+
except ImportError:
|
873
|
+
raise ImportError("tavily is not installed in the tool execution environment")
|
874
|
+
|
875
|
+
# Check if the API key exists
|
876
|
+
if tool_settings.tavily_api_key is None:
|
877
|
+
raise ValueError("TAVILY_API_KEY is not set")
|
878
|
+
|
879
|
+
# Instantiate client and search
|
880
|
+
tavily_client = AsyncTavilyClient(api_key=tool_settings.tavily_api_key)
|
881
|
+
search_results = await tavily_client.search(query=query, auto_parameters=True)
|
882
|
+
|
883
|
+
results = search_results.get("results", [])
|
884
|
+
if not results:
|
885
|
+
return "No search results found."
|
886
|
+
|
887
|
+
# ---- format for the LLM -------------------------------------------------
|
888
|
+
formatted_blocks = []
|
889
|
+
for idx, item in enumerate(results, start=1):
|
890
|
+
title = item.get("title") or "Untitled"
|
891
|
+
url = item.get("url") or "Unknown URL"
|
892
|
+
# keep each content snippet reasonably short so you don’t blow up context
|
893
|
+
content = (
|
894
|
+
shorten(item.get("content", "").strip(), width=600, placeholder=" …")
|
895
|
+
if WEB_SEARCH_CLIP_CONTENT
|
896
|
+
else item.get("content", "").strip()
|
897
|
+
)
|
898
|
+
score = item.get("score")
|
899
|
+
if WEB_SEARCH_INCLUDE_SCORE:
|
900
|
+
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Relevance score: {score:.4f}\n" f"Content: {content}\n"
|
901
|
+
else:
|
902
|
+
block = f"\nRESULT {idx}:\n" f"Title: {title}\n" f"URL: {url}\n" f"Content: {content}\n"
|
903
|
+
formatted_blocks.append(block)
|
904
|
+
|
905
|
+
return WEB_SEARCH_SEPARATOR.join(formatted_blocks)
|
letta/services/tool_manager.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
import asyncio
|
1
2
|
import importlib
|
2
3
|
import warnings
|
3
4
|
from typing import List, Optional
|
@@ -9,6 +10,7 @@ from letta.constants import (
|
|
9
10
|
BASE_TOOLS,
|
10
11
|
BASE_VOICE_SLEEPTIME_CHAT_TOOLS,
|
11
12
|
BASE_VOICE_SLEEPTIME_TOOLS,
|
13
|
+
BUILTIN_TOOLS,
|
12
14
|
LETTA_TOOL_SET,
|
13
15
|
MCP_TOOL_TAG_NAME_PREFIX,
|
14
16
|
MULTI_AGENT_TOOLS,
|
@@ -59,6 +61,32 @@ class ToolManager:
|
|
59
61
|
|
60
62
|
return tool
|
61
63
|
|
64
|
+
@enforce_types
|
65
|
+
async def create_or_update_tool_async(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
|
66
|
+
"""Create a new tool based on the ToolCreate schema."""
|
67
|
+
tool_id = await self.get_tool_id_by_name_async(tool_name=pydantic_tool.name, actor=actor)
|
68
|
+
if tool_id:
|
69
|
+
# Put to dict and remove fields that should not be reset
|
70
|
+
update_data = pydantic_tool.model_dump(exclude_unset=True, exclude_none=True)
|
71
|
+
|
72
|
+
# If there's anything to update
|
73
|
+
if update_data:
|
74
|
+
# In case we want to update the tool type
|
75
|
+
# Useful if we are shuffling around base tools
|
76
|
+
updated_tool_type = None
|
77
|
+
if "tool_type" in update_data:
|
78
|
+
updated_tool_type = update_data.get("tool_type")
|
79
|
+
tool = await self.update_tool_by_id_async(tool_id, ToolUpdate(**update_data), actor, updated_tool_type=updated_tool_type)
|
80
|
+
else:
|
81
|
+
printd(
|
82
|
+
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."
|
83
|
+
)
|
84
|
+
tool = await self.get_tool_by_id_async(tool_id, actor=actor)
|
85
|
+
else:
|
86
|
+
tool = await self.create_tool_async(pydantic_tool, actor=actor)
|
87
|
+
|
88
|
+
return tool
|
89
|
+
|
62
90
|
@enforce_types
|
63
91
|
def create_or_update_mcp_tool(self, tool_create: ToolCreate, mcp_server_name: str, actor: PydanticUser) -> PydanticTool:
|
64
92
|
metadata = {MCP_TOOL_TAG_NAME_PREFIX: {"server_name": mcp_server_name}}
|
@@ -96,6 +124,21 @@ class ToolManager:
|
|
96
124
|
tool.create(session, actor=actor) # Re-raise other database-related errors
|
97
125
|
return tool.to_pydantic()
|
98
126
|
|
127
|
+
@enforce_types
|
128
|
+
async def create_tool_async(self, pydantic_tool: PydanticTool, actor: PydanticUser) -> PydanticTool:
|
129
|
+
"""Create a new tool based on the ToolCreate schema."""
|
130
|
+
async with db_registry.async_session() as session:
|
131
|
+
# Set the organization id at the ORM layer
|
132
|
+
pydantic_tool.organization_id = actor.organization_id
|
133
|
+
# Auto-generate description if not provided
|
134
|
+
if pydantic_tool.description is None:
|
135
|
+
pydantic_tool.description = pydantic_tool.json_schema.get("description", None)
|
136
|
+
tool_data = pydantic_tool.model_dump(to_orm=True)
|
137
|
+
|
138
|
+
tool = ToolModel(**tool_data)
|
139
|
+
await tool.create_async(session, actor=actor) # Re-raise other database-related errors
|
140
|
+
return tool.to_pydantic()
|
141
|
+
|
99
142
|
@enforce_types
|
100
143
|
def get_tool_by_id(self, tool_id: str, actor: PydanticUser) -> PydanticTool:
|
101
144
|
"""Fetch a tool by its ID."""
|
@@ -105,6 +148,15 @@ class ToolManager:
|
|
105
148
|
# Convert the SQLAlchemy Tool object to PydanticTool
|
106
149
|
return tool.to_pydantic()
|
107
150
|
|
151
|
+
@enforce_types
|
152
|
+
async def get_tool_by_id_async(self, tool_id: str, actor: PydanticUser) -> PydanticTool:
|
153
|
+
"""Fetch a tool by its ID."""
|
154
|
+
async with db_registry.async_session() as session:
|
155
|
+
# Retrieve tool by id using the Tool model's read method
|
156
|
+
tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor)
|
157
|
+
# Convert the SQLAlchemy Tool object to PydanticTool
|
158
|
+
return tool.to_pydantic()
|
159
|
+
|
108
160
|
@enforce_types
|
109
161
|
def get_tool_by_name(self, tool_name: str, actor: PydanticUser) -> Optional[PydanticTool]:
|
110
162
|
"""Retrieve a tool by its name and a user. We derive the organization from the user, and retrieve that tool."""
|
@@ -135,6 +187,16 @@ class ToolManager:
|
|
135
187
|
except NoResultFound:
|
136
188
|
return None
|
137
189
|
|
190
|
+
@enforce_types
|
191
|
+
async def get_tool_id_by_name_async(self, tool_name: str, actor: PydanticUser) -> Optional[str]:
|
192
|
+
"""Retrieve a tool by its name and a user. We derive the organization from the user, and retrieve that tool."""
|
193
|
+
try:
|
194
|
+
async with db_registry.async_session() as session:
|
195
|
+
tool = await ToolModel.read_async(db_session=session, name=tool_name, actor=actor)
|
196
|
+
return tool.id
|
197
|
+
except NoResultFound:
|
198
|
+
return None
|
199
|
+
|
138
200
|
@enforce_types
|
139
201
|
async def list_tools_async(self, actor: PydanticUser, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticTool]:
|
140
202
|
"""List all tools with optional pagination."""
|
@@ -204,6 +266,35 @@ class ToolManager:
|
|
204
266
|
# Save the updated tool to the database
|
205
267
|
return tool.update(db_session=session, actor=actor).to_pydantic()
|
206
268
|
|
269
|
+
@enforce_types
|
270
|
+
async def update_tool_by_id_async(
|
271
|
+
self, tool_id: str, tool_update: ToolUpdate, actor: PydanticUser, updated_tool_type: Optional[ToolType] = None
|
272
|
+
) -> PydanticTool:
|
273
|
+
"""Update a tool by its ID with the given ToolUpdate object."""
|
274
|
+
async with db_registry.async_session() as session:
|
275
|
+
# Fetch the tool by ID
|
276
|
+
tool = await ToolModel.read_async(db_session=session, identifier=tool_id, actor=actor)
|
277
|
+
|
278
|
+
# Update tool attributes with only the fields that were explicitly set
|
279
|
+
update_data = tool_update.model_dump(to_orm=True, exclude_none=True)
|
280
|
+
for key, value in update_data.items():
|
281
|
+
setattr(tool, key, value)
|
282
|
+
|
283
|
+
# If source code is changed and a new json_schema is not provided, we want to auto-refresh the schema
|
284
|
+
if "source_code" in update_data.keys() and "json_schema" not in update_data.keys():
|
285
|
+
pydantic_tool = tool.to_pydantic()
|
286
|
+
new_schema = derive_openai_json_schema(source_code=pydantic_tool.source_code)
|
287
|
+
|
288
|
+
tool.json_schema = new_schema
|
289
|
+
tool.name = new_schema["name"]
|
290
|
+
|
291
|
+
if updated_tool_type:
|
292
|
+
tool.tool_type = updated_tool_type
|
293
|
+
|
294
|
+
# Save the updated tool to the database
|
295
|
+
tool = await tool.update_async(db_session=session, actor=actor)
|
296
|
+
return tool.to_pydantic()
|
297
|
+
|
207
298
|
@enforce_types
|
208
299
|
def delete_tool_by_id(self, tool_id: str, actor: PydanticUser) -> None:
|
209
300
|
"""Delete a tool by its ID."""
|
@@ -218,7 +309,7 @@ class ToolManager:
|
|
218
309
|
def upsert_base_tools(self, actor: PydanticUser) -> List[PydanticTool]:
|
219
310
|
"""Add default tools in base.py and multi_agent.py"""
|
220
311
|
functions_to_schema = {}
|
221
|
-
module_names = ["base", "multi_agent", "voice"]
|
312
|
+
module_names = ["base", "multi_agent", "voice", "builtin"]
|
222
313
|
|
223
314
|
for module_name in module_names:
|
224
315
|
full_module_name = f"letta.functions.function_sets.{module_name}"
|
@@ -254,6 +345,9 @@ class ToolManager:
|
|
254
345
|
elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS:
|
255
346
|
tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE
|
256
347
|
tags = [tool_type.value]
|
348
|
+
elif name in BUILTIN_TOOLS:
|
349
|
+
tool_type = ToolType.LETTA_BUILTIN
|
350
|
+
tags = [tool_type.value]
|
257
351
|
else:
|
258
352
|
raise ValueError(
|
259
353
|
f"Tool name {name} is not in the list of base tool names: {BASE_TOOLS + BASE_MEMORY_TOOLS + MULTI_AGENT_TOOLS + BASE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS}"
|
@@ -275,3 +369,68 @@ class ToolManager:
|
|
275
369
|
|
276
370
|
# TODO: Delete any base tools that are stale
|
277
371
|
return tools
|
372
|
+
|
373
|
+
@enforce_types
|
374
|
+
async def upsert_base_tools_async(self, actor: PydanticUser) -> List[PydanticTool]:
|
375
|
+
"""Add default tools in base.py and multi_agent.py"""
|
376
|
+
functions_to_schema = {}
|
377
|
+
module_names = ["base", "multi_agent", "voice", "builtin"]
|
378
|
+
|
379
|
+
for module_name in module_names:
|
380
|
+
full_module_name = f"letta.functions.function_sets.{module_name}"
|
381
|
+
try:
|
382
|
+
module = importlib.import_module(full_module_name)
|
383
|
+
except Exception as e:
|
384
|
+
# Handle other general exceptions
|
385
|
+
raise e
|
386
|
+
|
387
|
+
try:
|
388
|
+
# Load the function set
|
389
|
+
functions_to_schema.update(load_function_set(module))
|
390
|
+
except ValueError as e:
|
391
|
+
err = f"Error loading function set '{module_name}': {e}"
|
392
|
+
warnings.warn(err)
|
393
|
+
|
394
|
+
# create tool in db
|
395
|
+
tools = []
|
396
|
+
for name, schema in functions_to_schema.items():
|
397
|
+
if name in LETTA_TOOL_SET:
|
398
|
+
if name in BASE_TOOLS:
|
399
|
+
tool_type = ToolType.LETTA_CORE
|
400
|
+
tags = [tool_type.value]
|
401
|
+
elif name in BASE_MEMORY_TOOLS:
|
402
|
+
tool_type = ToolType.LETTA_MEMORY_CORE
|
403
|
+
tags = [tool_type.value]
|
404
|
+
elif name in MULTI_AGENT_TOOLS:
|
405
|
+
tool_type = ToolType.LETTA_MULTI_AGENT_CORE
|
406
|
+
tags = [tool_type.value]
|
407
|
+
elif name in BASE_SLEEPTIME_TOOLS:
|
408
|
+
tool_type = ToolType.LETTA_SLEEPTIME_CORE
|
409
|
+
tags = [tool_type.value]
|
410
|
+
elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS:
|
411
|
+
tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE
|
412
|
+
tags = [tool_type.value]
|
413
|
+
elif name in BUILTIN_TOOLS:
|
414
|
+
tool_type = ToolType.LETTA_BUILTIN
|
415
|
+
tags = [tool_type.value]
|
416
|
+
else:
|
417
|
+
raise ValueError(
|
418
|
+
f"Tool name {name} is not in the list of base tool names: {BASE_TOOLS + BASE_MEMORY_TOOLS + MULTI_AGENT_TOOLS + BASE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_TOOLS + BASE_VOICE_SLEEPTIME_CHAT_TOOLS}"
|
419
|
+
)
|
420
|
+
|
421
|
+
# create to tool
|
422
|
+
tools.append(
|
423
|
+
self.create_or_update_tool_async(
|
424
|
+
PydanticTool(
|
425
|
+
name=name,
|
426
|
+
tags=tags,
|
427
|
+
source_type="python",
|
428
|
+
tool_type=tool_type,
|
429
|
+
return_char_limit=BASE_FUNCTION_RETURN_CHAR_LIMIT,
|
430
|
+
),
|
431
|
+
actor=actor,
|
432
|
+
)
|
433
|
+
)
|
434
|
+
|
435
|
+
# TODO: Delete any base tools that are stale
|
436
|
+
return await asyncio.gather(*tools)
|
@@ -6,6 +6,7 @@ from letta.schemas.sandbox_config import SandboxConfig, SandboxType
|
|
6
6
|
from letta.schemas.tool import Tool
|
7
7
|
from letta.schemas.tool_execution_result import ToolExecutionResult
|
8
8
|
from letta.services.tool_sandbox.base import AsyncToolSandboxBase
|
9
|
+
from letta.tracing import log_event, trace_method
|
9
10
|
from letta.utils import get_friendly_error_msg
|
10
11
|
|
11
12
|
logger = get_logger(__name__)
|
@@ -27,6 +28,7 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
|
|
27
28
|
super().__init__(tool_name, args, user, tool_object, sandbox_config=sandbox_config, sandbox_env_vars=sandbox_env_vars)
|
28
29
|
self.force_recreate = force_recreate
|
29
30
|
|
31
|
+
@trace_method
|
30
32
|
async def run(
|
31
33
|
self,
|
32
34
|
agent_state: Optional[AgentState] = None,
|
@@ -44,6 +46,7 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
|
|
44
46
|
|
45
47
|
return result
|
46
48
|
|
49
|
+
@trace_method
|
47
50
|
async def run_e2b_sandbox(
|
48
51
|
self, agent_state: Optional[AgentState] = None, additional_env_vars: Optional[Dict] = None
|
49
52
|
) -> ToolExecutionResult:
|
@@ -81,10 +84,21 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
|
|
81
84
|
env_vars.update(additional_env_vars)
|
82
85
|
code = self.generate_execution_script(agent_state=agent_state)
|
83
86
|
|
87
|
+
log_event(
|
88
|
+
"e2b_execution_started",
|
89
|
+
{"tool": self.tool_name, "sandbox_id": e2b_sandbox.sandbox_id, "code": code, "env_vars": env_vars},
|
90
|
+
)
|
84
91
|
execution = await e2b_sandbox.run_code(code, envs=env_vars)
|
85
|
-
|
86
92
|
if execution.results:
|
87
93
|
func_return, agent_state = self.parse_best_effort(execution.results[0].text)
|
94
|
+
log_event(
|
95
|
+
"e2b_execution_succeeded",
|
96
|
+
{
|
97
|
+
"tool": self.tool_name,
|
98
|
+
"sandbox_id": e2b_sandbox.sandbox_id,
|
99
|
+
"func_return": func_return,
|
100
|
+
},
|
101
|
+
)
|
88
102
|
elif execution.error:
|
89
103
|
logger.error(f"Executing tool {self.tool_name} raised a {execution.error.name} with message: \n{execution.error.value}")
|
90
104
|
logger.error(f"Traceback from e2b sandbox: \n{execution.error.traceback}")
|
@@ -92,7 +106,25 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
|
|
92
106
|
function_name=self.tool_name, exception_name=execution.error.name, exception_message=execution.error.value
|
93
107
|
)
|
94
108
|
execution.logs.stderr.append(execution.error.traceback)
|
109
|
+
log_event(
|
110
|
+
"e2b_execution_failed",
|
111
|
+
{
|
112
|
+
"tool": self.tool_name,
|
113
|
+
"sandbox_id": e2b_sandbox.sandbox_id,
|
114
|
+
"error_type": execution.error.name,
|
115
|
+
"error_message": execution.error.value,
|
116
|
+
"func_return": func_return,
|
117
|
+
},
|
118
|
+
)
|
95
119
|
else:
|
120
|
+
log_event(
|
121
|
+
"e2b_execution_empty",
|
122
|
+
{
|
123
|
+
"tool": self.tool_name,
|
124
|
+
"sandbox_id": e2b_sandbox.sandbox_id,
|
125
|
+
"status": "no_results_no_error",
|
126
|
+
},
|
127
|
+
)
|
96
128
|
raise ValueError(f"Tool {self.tool_name} returned execution with None")
|
97
129
|
|
98
130
|
return ToolExecutionResult(
|
@@ -110,24 +142,54 @@ class AsyncToolSandboxE2B(AsyncToolSandboxBase):
|
|
110
142
|
exception_class = builtins_dict.get(e2b_execution.error.name, Exception)
|
111
143
|
return exception_class(e2b_execution.error.value)
|
112
144
|
|
145
|
+
@trace_method
|
113
146
|
async def create_e2b_sandbox_with_metadata_hash(self, sandbox_config: SandboxConfig) -> "Sandbox":
|
114
147
|
from e2b_code_interpreter import AsyncSandbox
|
115
148
|
|
116
149
|
state_hash = sandbox_config.fingerprint()
|
117
150
|
e2b_config = sandbox_config.get_e2b_config()
|
118
151
|
|
152
|
+
log_event(
|
153
|
+
"e2b_sandbox_create_started",
|
154
|
+
{
|
155
|
+
"sandbox_fingerprint": state_hash,
|
156
|
+
"e2b_config": e2b_config.model_dump(),
|
157
|
+
},
|
158
|
+
)
|
159
|
+
|
119
160
|
if e2b_config.template:
|
120
161
|
sbx = await AsyncSandbox.create(sandbox_config.get_e2b_config().template, metadata={self.METADATA_CONFIG_STATE_KEY: state_hash})
|
121
162
|
else:
|
122
|
-
# no template
|
123
163
|
sbx = await AsyncSandbox.create(
|
124
164
|
metadata={self.METADATA_CONFIG_STATE_KEY: state_hash}, **e2b_config.model_dump(exclude={"pip_requirements"})
|
125
165
|
)
|
126
166
|
|
127
|
-
|
167
|
+
log_event(
|
168
|
+
"e2b_sandbox_create_finished",
|
169
|
+
{
|
170
|
+
"sandbox_id": sbx.sandbox_id,
|
171
|
+
"sandbox_fingerprint": state_hash,
|
172
|
+
},
|
173
|
+
)
|
174
|
+
|
128
175
|
if e2b_config.pip_requirements:
|
129
176
|
for package in e2b_config.pip_requirements:
|
177
|
+
log_event(
|
178
|
+
"e2b_pip_install_started",
|
179
|
+
{
|
180
|
+
"sandbox_id": sbx.sandbox_id,
|
181
|
+
"package": package,
|
182
|
+
},
|
183
|
+
)
|
130
184
|
await sbx.commands.run(f"pip install {package}")
|
185
|
+
log_event(
|
186
|
+
"e2b_pip_install_finished",
|
187
|
+
{
|
188
|
+
"sandbox_id": sbx.sandbox_id,
|
189
|
+
"package": package,
|
190
|
+
},
|
191
|
+
)
|
192
|
+
|
131
193
|
return sbx
|
132
194
|
|
133
195
|
async def list_running_e2b_sandboxes(self):
|