Skip to content

Commit b802b22

Browse files
authored
feat: Add back multi agent tools and protections for cloud (letta-ai#3100)
1 parent 6edb6ec commit b802b22

7 files changed

Lines changed: 155 additions & 20 deletions

File tree

‎letta/constants.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
# Base tools that cannot be edited, as they access agent state directly
8484
# Note that we don't include "conversation_search_date" for now
8585
BASE_TOOLS = [SEND_MESSAGE_TOOL_NAME, "conversation_search", "archival_memory_insert", "archival_memory_search"]
86-
DEPRECATED_BASE_TOOLS = ["archival_memory_insert", "archival_memory_search"]
86+
DEPRECATED_LETTA_TOOLS = ["archival_memory_insert", "archival_memory_search"]
8787
# Base memory tools CAN be edited, and are added by default by the server
8888
BASE_MEMORY_TOOLS = ["core_memory_append", "core_memory_replace"]
8989
# New v2 collection of the base memory tools (effecitvely same as sleeptime set), to pair with memgpt_v2 prompt
@@ -115,7 +115,8 @@
115115
"finish_rethinking_memory",
116116
]
117117
# Multi agent tools
118-
MULTI_AGENT_TOOLS = ["send_message_to_agent_and_wait_for_reply", "send_message_to_agents_matching_tags"]
118+
MULTI_AGENT_TOOLS = ["send_message_to_agent_and_wait_for_reply", "send_message_to_agents_matching_tags", "send_message_to_agent_async"]
119+
LOCAL_ONLY_MULTI_AGENT_TOOLS = ["send_message_to_agent_async"]
119120

120121
# Used to catch if line numbers are pushed in
121122
# MEMORY_TOOLS_LINE_NUMBER_PREFIX_REGEX = re.compile(r"^Line \d+: ", re.MULTILINE)

‎letta/functions/function_sets/multi_agent.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import asyncio
22
import json
3+
import os
34
from concurrent.futures import ThreadPoolExecutor, as_completed
45
from typing import TYPE_CHECKING, List
56

67
from letta.functions.helpers import (
78
_send_message_to_all_agents_in_group_async,
89
execute_send_message_to_agent,
910
extract_send_message_from_steps_messages,
11+
fire_and_forget_send_to_agent,
1012
)
1113
from letta.schemas.enums import MessageRole
1214
from letta.schemas.message import MessageCreate
@@ -125,3 +127,35 @@ def send_message_to_all_agents_in_group(self: "Agent", message: str) -> List[str
125127
"""
126128

127129
return asyncio.run(_send_message_to_all_agents_in_group_async(self, message))
130+
131+
132+
def send_message_to_agent_async(self: "Agent", message: str, other_agent_id: str) -> str:
133+
"""
134+
Sends a message to a specific Letta agent within the same organization. The sender's identity is automatically included, so no explicit introduction is required in the message. This function does not expect a response from the target agent, making it suitable for notifications or one-way communication.
135+
Args:
136+
message (str): The content of the message to be sent to the target agent.
137+
other_agent_id (str): The unique identifier of the target Letta agent.
138+
Returns:
139+
str: A confirmation message indicating the message was successfully sent.
140+
"""
141+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
142+
raise RuntimeError("This tool is not allowed to be run on Letta Cloud.")
143+
144+
message = (
145+
f"[Incoming message from agent with ID '{self.agent_state.id}' - to reply to this message, "
146+
f"make sure to use the 'send_message_to_agent_async' tool, or the agent will not receive your message] "
147+
f"{message}"
148+
)
149+
messages = [MessageCreate(role=MessageRole.system, content=message, name=self.agent_state.name)]
150+
151+
# Do the actual fire-and-forget
152+
fire_and_forget_send_to_agent(
153+
sender_agent=self,
154+
messages=messages,
155+
other_agent_id=other_agent_id,
156+
log_prefix="[send_message_to_agent_async]",
157+
use_retries=False, # or True if you want to use _async_send_message_with_retries
158+
)
159+
160+
# Immediately return to caller
161+
return "Successfully sent message"

‎letta/services/agent_manager.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,8 @@
1616
BASE_VOICE_SLEEPTIME_CHAT_TOOLS,
1717
BASE_VOICE_SLEEPTIME_TOOLS,
1818
DEFAULT_TIMEZONE,
19-
DEPRECATED_BASE_TOOLS,
19+
DEPRECATED_LETTA_TOOLS,
2020
FILES_TOOLS,
21-
MULTI_AGENT_TOOLS,
2221
)
2322
from letta.helpers import ToolRulesSolver
2423
from letta.helpers.datetime_helpers import get_utc_time
@@ -79,6 +78,7 @@
7978
build_passage_query,
8079
build_source_passage_query,
8180
calculate_base_tools,
81+
calculate_multi_agent_tools,
8282
check_supports_structured_output,
8383
compile_system_message,
8484
derive_system_message,
@@ -271,7 +271,7 @@ def create_agent(self, agent_create: CreateAgent, actor: PydanticUser, _test_onl
271271
else:
272272
tool_names |= calculate_base_tools(is_v2=False)
273273
if agent_create.include_multi_agent_tools:
274-
tool_names |= set(MULTI_AGENT_TOOLS)
274+
tool_names |= calculate_multi_agent_tools()
275275

276276
supplied_ids = set(agent_create.tool_ids or [])
277277

@@ -294,7 +294,7 @@ def create_agent(self, agent_create: CreateAgent, actor: PydanticUser, _test_onl
294294
tool_rules = list(agent_create.tool_rules or [])
295295
if agent_create.include_base_tool_rules:
296296
for tn in tool_names:
297-
if tn in {"send_message", "memory_finish_edits"}:
297+
if tn in {"send_message", "send_message_to_agent_async", "memory_finish_edits"}:
298298
tool_rules.append(TerminalToolRule(tool_name=tn))
299299
elif tn in (BASE_TOOLS + BASE_MEMORY_TOOLS + BASE_SLEEPTIME_TOOLS):
300300
tool_rules.append(ContinueToolRule(tool_name=tn))
@@ -438,10 +438,10 @@ async def create_agent_async(
438438
else:
439439
tool_names |= calculate_base_tools(is_v2=False)
440440
if agent_create.include_multi_agent_tools:
441-
tool_names |= set(MULTI_AGENT_TOOLS)
441+
tool_names |= calculate_multi_agent_tools()
442442

443443
# take out the deprecated tool names
444-
tool_names.difference_update(set(DEPRECATED_BASE_TOOLS))
444+
tool_names.difference_update(set(DEPRECATED_LETTA_TOOLS))
445445

446446
supplied_ids = set(agent_create.tool_ids or [])
447447

@@ -479,7 +479,7 @@ async def create_agent_async(
479479
tool_rules = list(agent_create.tool_rules or [])
480480
if agent_create.include_base_tool_rules:
481481
for tn in tool_names:
482-
if tn in {"send_message", "memory_finish_edits"}:
482+
if tn in {"send_message", "send_message_to_agent_async", "memory_finish_edits"}:
483483
tool_rules.append(TerminalToolRule(tool_name=tn))
484484
elif tn in (BASE_TOOLS + BASE_MEMORY_TOOLS + BASE_MEMORY_TOOLS_V2 + BASE_SLEEPTIME_TOOLS):
485485
tool_rules.append(ContinueToolRule(tool_name=tn))

‎letta/services/helpers/agent_manager_helper.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from datetime import datetime
23
from typing import List, Literal, Optional, Set
34

@@ -10,9 +11,11 @@
1011
BASE_MEMORY_TOOLS,
1112
BASE_MEMORY_TOOLS_V2,
1213
BASE_TOOLS,
13-
DEPRECATED_BASE_TOOLS,
14+
DEPRECATED_LETTA_TOOLS,
1415
IN_CONTEXT_MEMORY_KEYWORD,
16+
LOCAL_ONLY_MULTI_AGENT_TOOLS,
1517
MAX_EMBEDDING_DIM,
18+
MULTI_AGENT_TOOLS,
1619
STRUCTURED_OUTPUT_MODELS,
1720
)
1821
from letta.embeddings import embedding_model
@@ -1050,6 +1053,14 @@ def build_agent_passage_query(
10501053

10511054
def calculate_base_tools(is_v2: bool) -> Set[str]:
10521055
if is_v2:
1053-
return (set(BASE_TOOLS) - set(DEPRECATED_BASE_TOOLS)) | set(BASE_MEMORY_TOOLS_V2)
1056+
return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS_V2)
10541057
else:
1055-
return (set(BASE_TOOLS) - set(DEPRECATED_BASE_TOOLS)) | set(BASE_MEMORY_TOOLS)
1058+
return (set(BASE_TOOLS) - set(DEPRECATED_LETTA_TOOLS)) | set(BASE_MEMORY_TOOLS)
1059+
1060+
1061+
def calculate_multi_agent_tools() -> Set[str]:
1062+
"""Calculate multi-agent tools, excluding local-only tools in production environment."""
1063+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
1064+
return set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
1065+
else:
1066+
return set(MULTI_AGENT_TOOLS)

‎letta/services/tool_executor/multi_agent_tool_executor.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
2+
import os
23
from typing import Any, Dict, List, Optional
34

5+
from letta.log import get_logger
46
from letta.schemas.agent import AgentState
57
from letta.schemas.enums import MessageRole
68
from letta.schemas.letta_message import AssistantMessage
@@ -12,6 +14,8 @@
1214
from letta.schemas.user import User
1315
from letta.services.tool_executor.tool_executor_base import ToolExecutor
1416

17+
logger = get_logger(__name__)
18+
1519

1620
class LettaMultiAgentToolExecutor(ToolExecutor):
1721
"""Executor for LETTA multi-agent core tools."""
@@ -29,6 +33,7 @@ async def execute(
2933
assert agent_state is not None, "Agent state is required for multi-agent tools"
3034
function_map = {
3135
"send_message_to_agent_and_wait_for_reply": self.send_message_to_agent_and_wait_for_reply,
36+
"send_message_to_agent_async": self.send_message_to_agent_async,
3237
"send_message_to_agents_matching_tags": self.send_message_to_agents_matching_tags_async,
3338
}
3439

@@ -105,3 +110,21 @@ async def _process_agent(self, agent_id: str, message: str) -> Dict[str, Any]:
105110
"error": str(e),
106111
"type": type(e).__name__,
107112
}
113+
114+
async def send_message_to_agent_async(self, agent_state: AgentState, message: str, other_agent_id: str) -> str:
115+
if os.getenv("LETTA_ENVIRONMENT") == "PRODUCTION":
116+
raise RuntimeError("This tool is not allowed to be run on Letta Cloud.")
117+
118+
# 1) Build the prefixed system‐message
119+
prefixed = (
120+
f"[Incoming message from agent with ID '{agent_state.id}' - "
121+
f"to reply to this message, make sure to use the "
122+
f"'send_message_to_agent_async' tool, or the agent will not receive your message] "
123+
f"{message}"
124+
)
125+
126+
task = asyncio.create_task(self._process_agent(agent_id=other_agent_id, message=prefixed))
127+
128+
task.add_done_callback(lambda t: (logger.error(f"Async send_message task failed: {t.exception()}") if t.exception() else None))
129+
130+
return "Successfully sent message"

‎letta/services/tool_manager.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
LETTA_TOOL_MODULE_NAMES,
1616
LETTA_TOOL_SET,
1717
MCP_TOOL_TAG_NAME_PREFIX,
18-
MULTI_AGENT_TOOLS,
1918
)
2019
from letta.functions.functions import derive_openai_json_schema, load_function_set
2120
from letta.log import get_logger
@@ -29,6 +28,7 @@
2928
from letta.schemas.tool import ToolCreate, ToolUpdate
3029
from letta.schemas.user import User as PydanticUser
3130
from letta.server.db import db_registry
31+
from letta.services.helpers.agent_manager_helper import calculate_multi_agent_tools
3232
from letta.services.mcp.types import SSEServerConfig, StdioServerConfig
3333
from letta.utils import enforce_types, printd
3434

@@ -419,7 +419,7 @@ def upsert_base_tools(self, actor: PydanticUser) -> List[PydanticTool]:
419419
elif name in BASE_MEMORY_TOOLS:
420420
tool_type = ToolType.LETTA_MEMORY_CORE
421421
tags = [tool_type.value]
422-
elif name in MULTI_AGENT_TOOLS:
422+
elif name in calculate_multi_agent_tools():
423423
tool_type = ToolType.LETTA_MULTI_AGENT_CORE
424424
tags = [tool_type.value]
425425
elif name in BASE_SLEEPTIME_TOOLS:
@@ -435,9 +435,8 @@ def upsert_base_tools(self, actor: PydanticUser) -> List[PydanticTool]:
435435
tool_type = ToolType.LETTA_FILES_CORE
436436
tags = [tool_type.value]
437437
else:
438-
raise ValueError(
439-
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}"
440-
)
438+
logger.warning(f"Tool name {name} is not in any known base tool set, skipping")
439+
continue
441440

442441
# create to tool
443442
tools.append(
@@ -486,7 +485,7 @@ async def upsert_base_tools_async(
486485
tool_type = ToolType.LETTA_MEMORY_CORE
487486
elif name in BASE_SLEEPTIME_TOOLS:
488487
tool_type = ToolType.LETTA_SLEEPTIME_CORE
489-
elif name in MULTI_AGENT_TOOLS:
488+
elif name in calculate_multi_agent_tools():
490489
tool_type = ToolType.LETTA_MULTI_AGENT_CORE
491490
elif name in BASE_VOICE_SLEEPTIME_TOOLS or name in BASE_VOICE_SLEEPTIME_CHAT_TOOLS:
492491
tool_type = ToolType.LETTA_VOICE_SLEEPTIME_CORE
@@ -495,7 +494,8 @@ async def upsert_base_tools_async(
495494
elif name in FILES_TOOLS:
496495
tool_type = ToolType.LETTA_FILES_CORE
497496
else:
498-
raise ValueError(f"Tool name {name} is not recognized in any known base tool set.")
497+
logger.warning(f"Tool name {name} is not in any known base tool set, skipping")
498+
continue
499499

500500
if allowed_types is not None and tool_type not in allowed_types:
501501
continue

‎tests/test_managers.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
FILES_TOOLS,
3535
LETTA_TOOL_EXECUTION_DIR,
3636
LETTA_TOOL_SET,
37+
LOCAL_ONLY_MULTI_AGENT_TOOLS,
3738
MCP_TOOL_TAG_NAME_PREFIX,
3839
MULTI_AGENT_TOOLS,
3940
)
@@ -86,7 +87,7 @@
8687
from letta.server.db import db_registry
8788
from letta.server.server import SyncServer
8889
from letta.services.block_manager import BlockManager
89-
from letta.services.helpers.agent_manager_helper import calculate_base_tools
90+
from letta.services.helpers.agent_manager_helper import calculate_base_tools, calculate_multi_agent_tools
9091
from letta.services.step_manager import FeedbackType
9192
from letta.settings import tool_settings
9293
from tests.helpers.utils import comprehensive_agent_checks, validate_context_window_overview
@@ -747,6 +748,71 @@ async def test_create_agent_include_base_tools(server: SyncServer, default_user,
747748
assert sorted(tool_names) == sorted(expected_tools)
748749

749750

751+
def test_calculate_multi_agent_tools(set_letta_environment):
752+
"""Test that calculate_multi_agent_tools excludes local-only tools in production."""
753+
result = calculate_multi_agent_tools()
754+
755+
if set_letta_environment == "PRODUCTION":
756+
# Production environment should exclude local-only tools
757+
expected_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
758+
assert result == expected_tools, "Production should exclude local-only multi-agent tools"
759+
assert not set(LOCAL_ONLY_MULTI_AGENT_TOOLS).intersection(result), "Production should not include local-only tools"
760+
761+
# Verify specific tools
762+
assert "send_message_to_agent_and_wait_for_reply" in result, "Standard multi-agent tools should be in production"
763+
assert "send_message_to_agents_matching_tags" in result, "Standard multi-agent tools should be in production"
764+
assert "send_message_to_agent_async" not in result, "Local-only tools should not be in production"
765+
else:
766+
# Non-production environment should include all multi-agent tools
767+
assert result == set(MULTI_AGENT_TOOLS), "Non-production should include all multi-agent tools"
768+
assert set(LOCAL_ONLY_MULTI_AGENT_TOOLS).issubset(result), "Non-production should include local-only tools"
769+
770+
# Verify specific tools
771+
assert "send_message_to_agent_and_wait_for_reply" in result, "All multi-agent tools should be in non-production"
772+
assert "send_message_to_agents_matching_tags" in result, "All multi-agent tools should be in non-production"
773+
assert "send_message_to_agent_async" in result, "Local-only tools should be in non-production"
774+
775+
776+
async def test_upsert_base_tools_excludes_local_only_in_production(server: SyncServer, default_user, set_letta_environment, event_loop):
777+
"""Test that upsert_base_tools excludes local-only multi-agent tools in production."""
778+
# Upsert all base tools
779+
tools = await server.tool_manager.upsert_base_tools_async(actor=default_user)
780+
tool_names = {tool.name for tool in tools}
781+
782+
if set_letta_environment == "PRODUCTION":
783+
# Production environment should exclude local-only multi-agent tools
784+
for local_only_tool in LOCAL_ONLY_MULTI_AGENT_TOOLS:
785+
assert local_only_tool not in tool_names, f"Local-only tool '{local_only_tool}' should not be upserted in production"
786+
787+
# But should include standard multi-agent tools
788+
standard_multi_agent_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
789+
for standard_tool in standard_multi_agent_tools:
790+
assert standard_tool in tool_names, f"Standard multi-agent tool '{standard_tool}' should be upserted in production"
791+
else:
792+
# Non-production environment should include all multi-agent tools
793+
for tool in MULTI_AGENT_TOOLS:
794+
assert tool in tool_names, f"Multi-agent tool '{tool}' should be upserted in non-production"
795+
796+
797+
async def test_upsert_multi_agent_tools_only(server: SyncServer, default_user, set_letta_environment, event_loop):
798+
"""Test that upserting only multi-agent tools respects production filtering."""
799+
from letta.orm.enums import ToolType
800+
801+
# Upsert only multi-agent tools
802+
tools = await server.tool_manager.upsert_base_tools_async(actor=default_user, allowed_types={ToolType.LETTA_MULTI_AGENT_CORE})
803+
tool_names = {tool.name for tool in tools}
804+
805+
if set_letta_environment == "PRODUCTION":
806+
# Should only have non-local multi-agent tools
807+
expected_tools = set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
808+
assert tool_names == expected_tools, "Production multi-agent upsert should exclude local-only tools"
809+
assert "send_message_to_agent_async" not in tool_names, "Local-only async tool should not be upserted in production"
810+
else:
811+
# Should have all multi-agent tools
812+
assert tool_names == set(MULTI_AGENT_TOOLS), "Non-production multi-agent upsert should include all tools"
813+
assert "send_message_to_agent_async" in tool_names, "Local-only async tool should be upserted in non-production"
814+
815+
750816
@pytest.mark.asyncio
751817
async def test_create_agent_with_default_source(server: SyncServer, default_user, print_tool, default_block, event_loop):
752818
"""Test agent creation with include_default_source=True"""

0 commit comments

Comments
 (0)