letta-nightly 0.6.37.dev20250311104150__py3-none-any.whl → 0.6.39.dev20250313104142__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.
Potentially problematic release.
This version of letta-nightly might be problematic. Click here for more details.
- letta/__init__.py +1 -1
- letta/agent.py +83 -23
- letta/agents/low_latency_agent.py +3 -2
- letta/client/client.py +1 -50
- letta/constants.py +4 -1
- letta/functions/function_sets/base.py +1 -1
- letta/functions/function_sets/multi_agent.py +9 -8
- letta/functions/helpers.py +47 -6
- letta/functions/schema_generator.py +47 -0
- letta/helpers/mcp_helpers.py +108 -0
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/google_ai_client.py +332 -0
- letta/llm_api/google_vertex_client.py +214 -0
- letta/llm_api/helpers.py +1 -2
- letta/llm_api/llm_api_tools.py +0 -1
- letta/llm_api/llm_client.py +48 -0
- letta/llm_api/llm_client_base.py +129 -0
- letta/local_llm/utils.py +30 -20
- letta/log.py +1 -1
- letta/memory.py +1 -1
- letta/orm/__init__.py +1 -0
- letta/orm/block.py +8 -0
- letta/orm/enums.py +2 -0
- letta/orm/identities_blocks.py +13 -0
- letta/orm/identity.py +9 -0
- letta/orm/sqlalchemy_base.py +4 -4
- letta/orm/step.py +1 -0
- letta/schemas/block.py +4 -48
- letta/schemas/identity.py +3 -0
- letta/schemas/letta_message.py +26 -0
- letta/schemas/message.py +69 -63
- letta/schemas/step.py +1 -0
- letta/schemas/tool.py +39 -2
- letta/serialize_schemas/agent.py +8 -1
- letta/server/rest_api/app.py +15 -0
- letta/server/rest_api/chat_completions_interface.py +2 -0
- letta/server/rest_api/interface.py +46 -13
- letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +2 -7
- letta/server/rest_api/routers/v1/agents.py +14 -10
- letta/server/rest_api/routers/v1/blocks.py +5 -1
- letta/server/rest_api/routers/v1/steps.py +2 -0
- letta/server/rest_api/routers/v1/tools.py +71 -1
- letta/server/rest_api/routers/v1/voice.py +3 -6
- letta/server/server.py +102 -5
- letta/services/agent_manager.py +58 -3
- letta/services/block_manager.py +10 -1
- letta/services/helpers/agent_manager_helper.py +12 -1
- letta/services/identity_manager.py +61 -15
- letta/services/message_manager.py +40 -0
- letta/services/step_manager.py +8 -1
- letta/services/summarizer/summarizer.py +1 -1
- letta/services/tool_manager.py +6 -0
- letta/settings.py +11 -12
- {letta_nightly-0.6.37.dev20250311104150.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/METADATA +20 -18
- {letta_nightly-0.6.37.dev20250311104150.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/RECORD +58 -52
- {letta_nightly-0.6.37.dev20250311104150.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.37.dev20250311104150.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/WHEEL +0 -0
- {letta_nightly-0.6.37.dev20250311104150.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/entry_points.txt +0 -0
letta/__init__.py
CHANGED
letta/agent.py
CHANGED
|
@@ -3,7 +3,7 @@ import time
|
|
|
3
3
|
import traceback
|
|
4
4
|
import warnings
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
-
from typing import Any, List, Optional, Tuple, Union
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
7
|
|
|
8
8
|
from openai.types.beta.function_tool import FunctionTool as OpenAITool
|
|
9
9
|
|
|
@@ -26,9 +26,11 @@ from letta.helpers import ToolRulesSolver
|
|
|
26
26
|
from letta.helpers.composio_helpers import get_composio_api_key
|
|
27
27
|
from letta.helpers.datetime_helpers import get_utc_time
|
|
28
28
|
from letta.helpers.json_helpers import json_dumps, json_loads
|
|
29
|
+
from letta.helpers.mcp_helpers import BaseMCPClient
|
|
29
30
|
from letta.interface import AgentInterface
|
|
30
31
|
from letta.llm_api.helpers import calculate_summarizer_cutoff, get_token_counts_for_messages, is_context_overflow_error
|
|
31
32
|
from letta.llm_api.llm_api_tools import create
|
|
33
|
+
from letta.llm_api.llm_client import LLMClient
|
|
32
34
|
from letta.local_llm.utils import num_tokens_from_functions, num_tokens_from_messages
|
|
33
35
|
from letta.log import get_logger
|
|
34
36
|
from letta.memory import summarize_messages
|
|
@@ -37,7 +39,7 @@ from letta.orm.enums import ToolType
|
|
|
37
39
|
from letta.schemas.agent import AgentState, AgentStepResponse, UpdateAgent
|
|
38
40
|
from letta.schemas.block import BlockUpdate
|
|
39
41
|
from letta.schemas.embedding_config import EmbeddingConfig
|
|
40
|
-
from letta.schemas.enums import MessageRole
|
|
42
|
+
from letta.schemas.enums import MessageContentType, MessageRole
|
|
41
43
|
from letta.schemas.memory import ContextWindowOverview, Memory
|
|
42
44
|
from letta.schemas.message import Message, ToolReturn
|
|
43
45
|
from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
|
|
@@ -91,6 +93,8 @@ class Agent(BaseAgent):
|
|
|
91
93
|
user: User,
|
|
92
94
|
# extras
|
|
93
95
|
first_message_verify_mono: bool = True, # TODO move to config?
|
|
96
|
+
# MCP sessions, state held in-memory in the server
|
|
97
|
+
mcp_clients: Optional[Dict[str, BaseMCPClient]] = None,
|
|
94
98
|
):
|
|
95
99
|
assert isinstance(agent_state.memory, Memory), f"Memory object is not of type Memory: {type(agent_state.memory)}"
|
|
96
100
|
# Hold a copy of the state that was used to init the agent
|
|
@@ -148,18 +152,22 @@ class Agent(BaseAgent):
|
|
|
148
152
|
# Logger that the Agent specifically can use, will also report the agent_state ID with the logs
|
|
149
153
|
self.logger = get_logger(agent_state.id)
|
|
150
154
|
|
|
155
|
+
# MCPClient, state/sessions managed by the server
|
|
156
|
+
self.mcp_clients = mcp_clients
|
|
157
|
+
|
|
151
158
|
def load_last_function_response(self):
|
|
152
159
|
"""Load the last function response from message history"""
|
|
153
160
|
in_context_messages = self.agent_manager.get_in_context_messages(agent_id=self.agent_state.id, actor=self.user)
|
|
154
161
|
for i in range(len(in_context_messages) - 1, -1, -1):
|
|
155
162
|
msg = in_context_messages[i]
|
|
156
|
-
if msg.role == MessageRole.tool and msg.text:
|
|
163
|
+
if msg.role == MessageRole.tool and msg.content and len(msg.content) == 1 and msg.content[0].type == MessageContentType.text:
|
|
164
|
+
text_content = msg.content[0].text
|
|
157
165
|
try:
|
|
158
|
-
response_json = json.loads(
|
|
166
|
+
response_json = json.loads(text_content)
|
|
159
167
|
if response_json.get("message"):
|
|
160
168
|
return response_json["message"]
|
|
161
169
|
except (json.JSONDecodeError, KeyError):
|
|
162
|
-
raise ValueError(f"Invalid JSON format in message: {
|
|
170
|
+
raise ValueError(f"Invalid JSON format in message: {text_content}")
|
|
163
171
|
return None
|
|
164
172
|
|
|
165
173
|
def update_memory_if_changed(self, new_memory: Memory) -> bool:
|
|
@@ -196,6 +204,7 @@ class Agent(BaseAgent):
|
|
|
196
204
|
return True
|
|
197
205
|
return False
|
|
198
206
|
|
|
207
|
+
# TODO: Refactor into separate class v.s. large if/elses here
|
|
199
208
|
def execute_tool_and_persist_state(
|
|
200
209
|
self, function_name: str, function_args: dict, target_letta_tool: Tool
|
|
201
210
|
) -> tuple[Any, Optional[SandboxRunResult]]:
|
|
@@ -236,6 +245,32 @@ class Agent(BaseAgent):
|
|
|
236
245
|
function_response = execute_composio_action(
|
|
237
246
|
action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id
|
|
238
247
|
)
|
|
248
|
+
elif target_letta_tool.tool_type == ToolType.EXTERNAL_MCP:
|
|
249
|
+
# Get the server name from the tool tag
|
|
250
|
+
# TODO make a property instead?
|
|
251
|
+
server_name = target_letta_tool.tags[0].split(":")[1]
|
|
252
|
+
|
|
253
|
+
# Get the MCPClient from the server's handle
|
|
254
|
+
# TODO these don't get raised properly
|
|
255
|
+
if not self.mcp_clients:
|
|
256
|
+
raise ValueError(f"No MCP client available to use")
|
|
257
|
+
if server_name not in self.mcp_clients:
|
|
258
|
+
raise ValueError(f"Unknown MCP server name: {server_name}")
|
|
259
|
+
mcp_client = self.mcp_clients[server_name]
|
|
260
|
+
if not isinstance(mcp_client, BaseMCPClient):
|
|
261
|
+
raise RuntimeError(f"Expected an MCPClient, but got: {type(mcp_client)}")
|
|
262
|
+
|
|
263
|
+
# Check that tool exists
|
|
264
|
+
available_tools = mcp_client.list_tools()
|
|
265
|
+
available_tool_names = [t.name for t in available_tools]
|
|
266
|
+
if function_name not in available_tool_names:
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"{function_name} is not available in MCP server {server_name}. Please check your `~/.letta/mcp_config.json` file."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
function_response, is_error = mcp_client.execute_tool(tool_name=function_name, tool_args=function_args)
|
|
272
|
+
sandbox_run_result = SandboxRunResult(status="error" if is_error else "success")
|
|
273
|
+
return function_response, sandbox_run_result
|
|
239
274
|
else:
|
|
240
275
|
try:
|
|
241
276
|
# Parse the source code to extract function annotations
|
|
@@ -266,6 +301,7 @@ class Agent(BaseAgent):
|
|
|
266
301
|
function_response = get_friendly_error_msg(
|
|
267
302
|
function_name=function_name, exception_name=type(e).__name__, exception_message=str(e)
|
|
268
303
|
)
|
|
304
|
+
return function_response, SandboxRunResult(status="error")
|
|
269
305
|
|
|
270
306
|
return function_response, None
|
|
271
307
|
|
|
@@ -356,19 +392,38 @@ class Agent(BaseAgent):
|
|
|
356
392
|
for attempt in range(1, empty_response_retry_limit + 1):
|
|
357
393
|
try:
|
|
358
394
|
log_telemetry(self.logger, "_get_ai_reply create start")
|
|
359
|
-
|
|
395
|
+
# New LLM client flow
|
|
396
|
+
llm_client = LLMClient.create(
|
|
397
|
+
agent_id=self.agent_state.id,
|
|
360
398
|
llm_config=self.agent_state.llm_config,
|
|
361
|
-
messages=message_sequence,
|
|
362
|
-
user_id=self.agent_state.created_by_id,
|
|
363
|
-
functions=allowed_functions,
|
|
364
|
-
# functions_python=self.functions_python, do we need this?
|
|
365
|
-
function_call=function_call,
|
|
366
|
-
first_message=first_message,
|
|
367
|
-
force_tool_call=force_tool_call,
|
|
368
|
-
stream=stream,
|
|
369
|
-
stream_interface=self.interface,
|
|
370
399
|
put_inner_thoughts_first=put_inner_thoughts_first,
|
|
400
|
+
actor_id=self.agent_state.created_by_id,
|
|
371
401
|
)
|
|
402
|
+
|
|
403
|
+
if llm_client and not stream:
|
|
404
|
+
response = llm_client.send_llm_request(
|
|
405
|
+
messages=message_sequence,
|
|
406
|
+
tools=allowed_functions,
|
|
407
|
+
tool_call=function_call,
|
|
408
|
+
stream=stream,
|
|
409
|
+
first_message=first_message,
|
|
410
|
+
force_tool_call=force_tool_call,
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
# Fallback to existing flow
|
|
414
|
+
response = create(
|
|
415
|
+
llm_config=self.agent_state.llm_config,
|
|
416
|
+
messages=message_sequence,
|
|
417
|
+
user_id=self.agent_state.created_by_id,
|
|
418
|
+
functions=allowed_functions,
|
|
419
|
+
# functions_python=self.functions_python, do we need this?
|
|
420
|
+
function_call=function_call,
|
|
421
|
+
first_message=first_message,
|
|
422
|
+
force_tool_call=force_tool_call,
|
|
423
|
+
stream=stream,
|
|
424
|
+
stream_interface=self.interface,
|
|
425
|
+
put_inner_thoughts_first=put_inner_thoughts_first,
|
|
426
|
+
)
|
|
372
427
|
log_telemetry(self.logger, "_get_ai_reply create finish")
|
|
373
428
|
|
|
374
429
|
# These bottom two are retryable
|
|
@@ -632,7 +687,7 @@ class Agent(BaseAgent):
|
|
|
632
687
|
function_args,
|
|
633
688
|
function_response,
|
|
634
689
|
messages,
|
|
635
|
-
[tool_return]
|
|
690
|
+
[tool_return],
|
|
636
691
|
include_function_failed_message=True,
|
|
637
692
|
)
|
|
638
693
|
return messages, False, True # force a heartbeat to allow agent to handle error
|
|
@@ -659,7 +714,7 @@ class Agent(BaseAgent):
|
|
|
659
714
|
"content": function_response,
|
|
660
715
|
"tool_call_id": tool_call_id,
|
|
661
716
|
},
|
|
662
|
-
tool_returns=[tool_return] if
|
|
717
|
+
tool_returns=[tool_return] if sandbox_run_result else None,
|
|
663
718
|
)
|
|
664
719
|
) # extend conversation with function response
|
|
665
720
|
self.interface.function_message(f"Ran {function_name}({function_args})", msg_obj=messages[-1])
|
|
@@ -909,6 +964,7 @@ class Agent(BaseAgent):
|
|
|
909
964
|
# Log step - this must happen before messages are persisted
|
|
910
965
|
step = self.step_manager.log_step(
|
|
911
966
|
actor=self.user,
|
|
967
|
+
agent_id=self.agent_state.id,
|
|
912
968
|
provider_name=self.agent_state.llm_config.model_endpoint_type,
|
|
913
969
|
model=self.agent_state.llm_config.model,
|
|
914
970
|
model_endpoint=self.agent_state.llm_config.model_endpoint,
|
|
@@ -989,7 +1045,7 @@ class Agent(BaseAgent):
|
|
|
989
1045
|
err_msg,
|
|
990
1046
|
details={
|
|
991
1047
|
"num_in_context_messages": len(self.agent_state.message_ids),
|
|
992
|
-
"in_context_messages_text": [m.
|
|
1048
|
+
"in_context_messages_text": [m.content for m in in_context_messages],
|
|
993
1049
|
"token_counts": token_counts,
|
|
994
1050
|
},
|
|
995
1051
|
)
|
|
@@ -1143,14 +1199,17 @@ class Agent(BaseAgent):
|
|
|
1143
1199
|
if (
|
|
1144
1200
|
len(in_context_messages) > 1
|
|
1145
1201
|
and in_context_messages[1].role == MessageRole.user
|
|
1146
|
-
and
|
|
1202
|
+
and in_context_messages[1].content
|
|
1203
|
+
and len(in_context_messages[1].content) == 1
|
|
1204
|
+
and in_context_messages[1].content[0].type == MessageContentType.text
|
|
1147
1205
|
# TODO remove hardcoding
|
|
1148
|
-
and "The following is a summary of the previous " in in_context_messages[1].text
|
|
1206
|
+
and "The following is a summary of the previous " in in_context_messages[1].content[0].text
|
|
1149
1207
|
):
|
|
1150
1208
|
# Summary message exists
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1209
|
+
text_content = in_context_messages[1].content[0].text
|
|
1210
|
+
assert text_content is not None
|
|
1211
|
+
summary_memory = text_content
|
|
1212
|
+
num_tokens_summary_memory = count_tokens(text_content)
|
|
1154
1213
|
# with a summary message, the real messages start at index 2
|
|
1155
1214
|
num_tokens_messages = (
|
|
1156
1215
|
num_tokens_from_messages(messages=in_context_messages_openai[2:], model=self.model)
|
|
@@ -1174,6 +1233,7 @@ class Agent(BaseAgent):
|
|
|
1174
1233
|
memory_edit_timestamp=get_utc_time(),
|
|
1175
1234
|
previous_message_count=self.message_manager.size(actor=self.user, agent_id=self.agent_state.id),
|
|
1176
1235
|
archival_memory_size=self.agent_manager.passage_size(actor=self.user, agent_id=self.agent_state.id),
|
|
1236
|
+
recent_passages=self.agent_manager.list_passages(actor=self.user, agent_id=self.agent_state.id, ascending=False, limit=10),
|
|
1177
1237
|
)
|
|
1178
1238
|
num_tokens_external_memory_summary = count_tokens(external_memory_summary)
|
|
1179
1239
|
|
|
@@ -237,7 +237,8 @@ class LowLatencyAgent(BaseAgent):
|
|
|
237
237
|
# TODO: This is a pretty brittle pattern established all over our code, need to get rid of this
|
|
238
238
|
curr_system_message = in_context_messages[0]
|
|
239
239
|
curr_memory_str = agent_state.memory.compile()
|
|
240
|
-
|
|
240
|
+
curr_system_message_text = curr_system_message.content[0].text
|
|
241
|
+
if curr_memory_str in curr_system_message_text:
|
|
241
242
|
# NOTE: could this cause issues if a block is removed? (substring match would still work)
|
|
242
243
|
logger.debug(
|
|
243
244
|
f"Memory hasn't changed for agent id={agent_state.id} and actor=({self.actor.id}, {self.actor.name}), skipping system prompt rebuild"
|
|
@@ -251,7 +252,7 @@ class LowLatencyAgent(BaseAgent):
|
|
|
251
252
|
in_context_memory_last_edit=memory_edit_timestamp,
|
|
252
253
|
)
|
|
253
254
|
|
|
254
|
-
diff = united_diff(
|
|
255
|
+
diff = united_diff(curr_system_message_text, new_system_message_str)
|
|
255
256
|
if len(diff) > 0:
|
|
256
257
|
logger.info(f"Rebuilding system with new memory...\nDiff:\n{diff}")
|
|
257
258
|
|
letta/client/client.py
CHANGED
|
@@ -4,7 +4,6 @@ import time
|
|
|
4
4
|
from typing import Callable, Dict, Generator, List, Optional, Union
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
|
-
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall as OpenAIToolCall
|
|
8
7
|
|
|
9
8
|
import letta.utils
|
|
10
9
|
from letta.constants import ADMIN_PREFIX, BASE_MEMORY_TOOLS, BASE_TOOLS, DEFAULT_HUMAN, DEFAULT_PERSONA, FUNCTION_RETURN_CHAR_LIMIT
|
|
@@ -29,7 +28,7 @@ from letta.schemas.letta_request import LettaRequest, LettaStreamingRequest
|
|
|
29
28
|
from letta.schemas.letta_response import LettaResponse, LettaStreamingResponse
|
|
30
29
|
from letta.schemas.llm_config import LLMConfig
|
|
31
30
|
from letta.schemas.memory import ArchivalMemorySummary, ChatMemory, CreateArchivalMemory, Memory, RecallMemorySummary
|
|
32
|
-
from letta.schemas.message import Message, MessageCreate
|
|
31
|
+
from letta.schemas.message import Message, MessageCreate
|
|
33
32
|
from letta.schemas.openai.chat_completion_response import UsageStatistics
|
|
34
33
|
from letta.schemas.organization import Organization
|
|
35
34
|
from letta.schemas.passage import Passage
|
|
@@ -640,30 +639,6 @@ class RESTClient(AbstractClient):
|
|
|
640
639
|
# refresh and return agent
|
|
641
640
|
return self.get_agent(agent_state.id)
|
|
642
641
|
|
|
643
|
-
def update_message(
|
|
644
|
-
self,
|
|
645
|
-
agent_id: str,
|
|
646
|
-
message_id: str,
|
|
647
|
-
role: Optional[MessageRole] = None,
|
|
648
|
-
text: Optional[str] = None,
|
|
649
|
-
name: Optional[str] = None,
|
|
650
|
-
tool_calls: Optional[List[OpenAIToolCall]] = None,
|
|
651
|
-
tool_call_id: Optional[str] = None,
|
|
652
|
-
) -> Message:
|
|
653
|
-
request = MessageUpdate(
|
|
654
|
-
role=role,
|
|
655
|
-
content=text,
|
|
656
|
-
name=name,
|
|
657
|
-
tool_calls=tool_calls,
|
|
658
|
-
tool_call_id=tool_call_id,
|
|
659
|
-
)
|
|
660
|
-
response = requests.patch(
|
|
661
|
-
f"{self.base_url}/{self.api_prefix}/agents/{agent_id}/messages/{message_id}", json=request.model_dump(), headers=self.headers
|
|
662
|
-
)
|
|
663
|
-
if response.status_code != 200:
|
|
664
|
-
raise ValueError(f"Failed to update message: {response.text}")
|
|
665
|
-
return Message(**response.json())
|
|
666
|
-
|
|
667
642
|
def update_agent(
|
|
668
643
|
self,
|
|
669
644
|
agent_id: str,
|
|
@@ -2436,30 +2411,6 @@ class LocalClient(AbstractClient):
|
|
|
2436
2411
|
# TODO: get full agent state
|
|
2437
2412
|
return self.server.agent_manager.get_agent_by_id(agent_state.id, actor=self.user)
|
|
2438
2413
|
|
|
2439
|
-
def update_message(
|
|
2440
|
-
self,
|
|
2441
|
-
agent_id: str,
|
|
2442
|
-
message_id: str,
|
|
2443
|
-
role: Optional[MessageRole] = None,
|
|
2444
|
-
text: Optional[str] = None,
|
|
2445
|
-
name: Optional[str] = None,
|
|
2446
|
-
tool_calls: Optional[List[OpenAIToolCall]] = None,
|
|
2447
|
-
tool_call_id: Optional[str] = None,
|
|
2448
|
-
) -> Message:
|
|
2449
|
-
message = self.server.update_agent_message(
|
|
2450
|
-
agent_id=agent_id,
|
|
2451
|
-
message_id=message_id,
|
|
2452
|
-
request=MessageUpdate(
|
|
2453
|
-
role=role,
|
|
2454
|
-
content=text,
|
|
2455
|
-
name=name,
|
|
2456
|
-
tool_calls=tool_calls,
|
|
2457
|
-
tool_call_id=tool_call_id,
|
|
2458
|
-
),
|
|
2459
|
-
actor=self.user,
|
|
2460
|
-
)
|
|
2461
|
-
return message
|
|
2462
|
-
|
|
2463
2414
|
def update_agent(
|
|
2464
2415
|
self,
|
|
2465
2416
|
agent_id: str,
|
letta/constants.py
CHANGED
|
@@ -11,6 +11,9 @@ OPENAI_API_PREFIX = "/openai"
|
|
|
11
11
|
COMPOSIO_ENTITY_ENV_VAR_KEY = "COMPOSIO_ENTITY"
|
|
12
12
|
COMPOSIO_TOOL_TAG_NAME = "composio"
|
|
13
13
|
|
|
14
|
+
MCP_CONFIG_NAME = "mcp_config.json"
|
|
15
|
+
MCP_TOOL_TAG_NAME_PREFIX = "mcp" # full format, mcp:server_name
|
|
16
|
+
|
|
14
17
|
LETTA_CORE_TOOL_MODULE_NAME = "letta.functions.function_sets.base"
|
|
15
18
|
LETTA_MULTI_AGENT_TOOL_MODULE_NAME = "letta.functions.function_sets.multi_agent"
|
|
16
19
|
|
|
@@ -50,7 +53,7 @@ BASE_TOOLS = ["send_message", "conversation_search", "archival_memory_insert", "
|
|
|
50
53
|
# Base memory tools CAN be edited, and are added by default by the server
|
|
51
54
|
BASE_MEMORY_TOOLS = ["core_memory_append", "core_memory_replace"]
|
|
52
55
|
# Multi agent tools
|
|
53
|
-
MULTI_AGENT_TOOLS = ["send_message_to_agent_and_wait_for_reply", "
|
|
56
|
+
MULTI_AGENT_TOOLS = ["send_message_to_agent_and_wait_for_reply", "send_message_to_agents_matching_tags", "send_message_to_agent_async"]
|
|
54
57
|
# Set of all built-in Letta tools
|
|
55
58
|
LETTA_TOOL_SET = set(BASE_TOOLS + BASE_MEMORY_TOOLS + MULTI_AGENT_TOOLS)
|
|
56
59
|
|
|
@@ -56,7 +56,7 @@ def conversation_search(self: "Agent", query: str, page: Optional[int] = 0) -> O
|
|
|
56
56
|
results_str = f"No results found."
|
|
57
57
|
else:
|
|
58
58
|
results_pref = f"Showing {len(messages)} of {total} results (page {page}/{num_pages}):"
|
|
59
|
-
results_formatted = [message.text for message in messages]
|
|
59
|
+
results_formatted = [message.content[0].text for message in messages]
|
|
60
60
|
results_str = f"{results_pref} {json_dumps(results_formatted)}"
|
|
61
61
|
return results_str
|
|
62
62
|
|
|
@@ -2,7 +2,7 @@ import asyncio
|
|
|
2
2
|
from typing import TYPE_CHECKING, List
|
|
3
3
|
|
|
4
4
|
from letta.functions.helpers import (
|
|
5
|
-
|
|
5
|
+
_send_message_to_agents_matching_tags_async,
|
|
6
6
|
execute_send_message_to_agent,
|
|
7
7
|
fire_and_forget_send_to_agent,
|
|
8
8
|
)
|
|
@@ -70,18 +70,19 @@ def send_message_to_agent_async(self: "Agent", message: str, other_agent_id: str
|
|
|
70
70
|
return "Successfully sent message"
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
def
|
|
73
|
+
def send_message_to_agents_matching_tags(self: "Agent", message: str, match_all: List[str], match_some: List[str]) -> List[str]:
|
|
74
74
|
"""
|
|
75
|
-
Sends a message to all agents within the same organization that match
|
|
75
|
+
Sends a message to all agents within the same organization that match the specified tag criteria. Agents must possess *all* of the tags in `match_all` and *at least one* of the tags in `match_some` to receive the message.
|
|
76
76
|
|
|
77
77
|
Args:
|
|
78
78
|
message (str): The content of the message to be sent to each matching agent.
|
|
79
|
-
|
|
79
|
+
match_all (List[str]): A list of tags that an agent must possess to receive the message.
|
|
80
|
+
match_some (List[str]): A list of tags where an agent must have at least one to qualify.
|
|
80
81
|
|
|
81
82
|
Returns:
|
|
82
|
-
List[str]: A list of responses from the agents that matched
|
|
83
|
-
response corresponds to a single agent. Agents that do not respond will not
|
|
84
|
-
|
|
83
|
+
List[str]: A list of responses from the agents that matched the filtering criteria. Each
|
|
84
|
+
response corresponds to a single agent. Agents that do not respond will not have an entry
|
|
85
|
+
in the returned list.
|
|
85
86
|
"""
|
|
86
87
|
|
|
87
|
-
return asyncio.run(
|
|
88
|
+
return asyncio.run(_send_message_to_agents_matching_tags_async(self, message, match_all, match_some))
|
letta/functions/helpers.py
CHANGED
|
@@ -48,6 +48,20 @@ def generate_composio_action_from_func_name(func_name: str) -> str:
|
|
|
48
48
|
return func_name.upper()
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
# TODO needed?
|
|
52
|
+
def generate_mcp_tool_wrapper(mcp_tool_name: str) -> tuple[str, str]:
|
|
53
|
+
|
|
54
|
+
wrapper_function_str = f"""\
|
|
55
|
+
def {mcp_tool_name}(**kwargs):
|
|
56
|
+
raise RuntimeError("Something went wrong - we should never be using the persisted source code for MCP. Please reach out to Letta team")
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Compile safety check
|
|
60
|
+
assert_code_gen_compilable(wrapper_function_str.strip())
|
|
61
|
+
|
|
62
|
+
return mcp_tool_name, wrapper_function_str.strip()
|
|
63
|
+
|
|
64
|
+
|
|
51
65
|
def generate_composio_tool_wrapper(action_name: str) -> tuple[str, str]:
|
|
52
66
|
# Generate func name
|
|
53
67
|
func_name = generate_func_name_from_composio_action(action_name)
|
|
@@ -518,8 +532,16 @@ def fire_and_forget_send_to_agent(
|
|
|
518
532
|
run_in_background_thread(background_task())
|
|
519
533
|
|
|
520
534
|
|
|
521
|
-
async def
|
|
522
|
-
|
|
535
|
+
async def _send_message_to_agents_matching_tags_async(
|
|
536
|
+
sender_agent: "Agent", message: str, match_all: List[str], match_some: List[str]
|
|
537
|
+
) -> List[str]:
|
|
538
|
+
log_telemetry(
|
|
539
|
+
sender_agent.logger,
|
|
540
|
+
"_send_message_to_agents_matching_tags_async start",
|
|
541
|
+
message=message,
|
|
542
|
+
match_all=match_all,
|
|
543
|
+
match_some=match_some,
|
|
544
|
+
)
|
|
523
545
|
server = get_letta_server()
|
|
524
546
|
|
|
525
547
|
augmented_message = (
|
|
@@ -529,9 +551,22 @@ async def _send_message_to_agents_matching_all_tags_async(sender_agent: "Agent",
|
|
|
529
551
|
)
|
|
530
552
|
|
|
531
553
|
# Retrieve up to 100 matching agents
|
|
532
|
-
log_telemetry(
|
|
533
|
-
|
|
534
|
-
|
|
554
|
+
log_telemetry(
|
|
555
|
+
sender_agent.logger,
|
|
556
|
+
"_send_message_to_agents_matching_tags_async listing agents start",
|
|
557
|
+
message=message,
|
|
558
|
+
match_all=match_all,
|
|
559
|
+
match_some=match_some,
|
|
560
|
+
)
|
|
561
|
+
matching_agents = server.agent_manager.list_agents_matching_tags(actor=sender_agent.user, match_all=match_all, match_some=match_some)
|
|
562
|
+
|
|
563
|
+
log_telemetry(
|
|
564
|
+
sender_agent.logger,
|
|
565
|
+
"_send_message_to_agents_matching_tags_async listing agents finish",
|
|
566
|
+
message=message,
|
|
567
|
+
match_all=match_all,
|
|
568
|
+
match_some=match_some,
|
|
569
|
+
)
|
|
535
570
|
|
|
536
571
|
# Create a system message
|
|
537
572
|
messages = [MessageCreate(role=MessageRole.system, content=augmented_message, name=sender_agent.agent_state.name)]
|
|
@@ -559,7 +594,13 @@ async def _send_message_to_agents_matching_all_tags_async(sender_agent: "Agent",
|
|
|
559
594
|
else:
|
|
560
595
|
final.append(r)
|
|
561
596
|
|
|
562
|
-
log_telemetry(
|
|
597
|
+
log_telemetry(
|
|
598
|
+
sender_agent.logger,
|
|
599
|
+
"_send_message_to_agents_matching_tags_async finish",
|
|
600
|
+
message=message,
|
|
601
|
+
match_all=match_all,
|
|
602
|
+
match_some=match_some,
|
|
603
|
+
)
|
|
563
604
|
return final
|
|
564
605
|
|
|
565
606
|
|
|
@@ -6,6 +6,8 @@ from composio.client.collections import ActionParametersModel
|
|
|
6
6
|
from docstring_parser import parse
|
|
7
7
|
from pydantic import BaseModel
|
|
8
8
|
|
|
9
|
+
from letta.helpers.mcp_helpers import MCPTool
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
def is_optional(annotation):
|
|
11
13
|
# Check if the annotation is a Union
|
|
@@ -447,6 +449,51 @@ def generate_schema_from_args_schema_v2(
|
|
|
447
449
|
return function_call_json
|
|
448
450
|
|
|
449
451
|
|
|
452
|
+
def generate_tool_schema_for_mcp(
|
|
453
|
+
mcp_tool: MCPTool,
|
|
454
|
+
append_heartbeat: bool = True,
|
|
455
|
+
strict: bool = False,
|
|
456
|
+
) -> Dict[str, Any]:
|
|
457
|
+
|
|
458
|
+
# MCP tool.inputSchema is a JSON schema
|
|
459
|
+
# https://github.com/modelcontextprotocol/python-sdk/blob/775f87981300660ee957b63c2a14b448ab9c3675/src/mcp/types.py#L678
|
|
460
|
+
parameters_schema = mcp_tool.inputSchema
|
|
461
|
+
name = mcp_tool.name
|
|
462
|
+
description = mcp_tool.description
|
|
463
|
+
|
|
464
|
+
assert "type" in parameters_schema
|
|
465
|
+
assert "required" in parameters_schema
|
|
466
|
+
assert "properties" in parameters_schema
|
|
467
|
+
|
|
468
|
+
# Add the optional heartbeat parameter
|
|
469
|
+
if append_heartbeat:
|
|
470
|
+
parameters_schema["properties"]["request_heartbeat"] = {
|
|
471
|
+
"type": "boolean",
|
|
472
|
+
"description": "Request an immediate heartbeat after function execution. Set to `True` if you want to send a follow-up message or run a follow-up function.",
|
|
473
|
+
}
|
|
474
|
+
parameters_schema["required"].append("request_heartbeat")
|
|
475
|
+
|
|
476
|
+
# Return the final schema
|
|
477
|
+
if strict:
|
|
478
|
+
# https://platform.openai.com/docs/guides/function-calling#strict-mode
|
|
479
|
+
|
|
480
|
+
# Add additionalProperties: False
|
|
481
|
+
parameters_schema["additionalProperties"] = False
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"strict": True, # NOTE
|
|
485
|
+
"name": name,
|
|
486
|
+
"description": description,
|
|
487
|
+
"parameters": parameters_schema,
|
|
488
|
+
}
|
|
489
|
+
else:
|
|
490
|
+
return {
|
|
491
|
+
"name": name,
|
|
492
|
+
"description": description,
|
|
493
|
+
"parameters": parameters_schema,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
450
497
|
def generate_tool_schema_for_composio(
|
|
451
498
|
parameters_model: ActionParametersModel,
|
|
452
499
|
name: str,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from mcp import ClientSession, StdioServerParameters, Tool
|
|
6
|
+
from mcp.client.sse import sse_client
|
|
7
|
+
from mcp.client.stdio import stdio_client
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from letta.log import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MCPTool(Tool):
|
|
16
|
+
"""A simple wrapper around MCP's tool definition (to avoid conflict with our own)"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPServerType(str, Enum):
|
|
20
|
+
SSE = "sse"
|
|
21
|
+
LOCAL = "local"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseServerConfig(BaseModel):
|
|
25
|
+
server_name: str = Field(..., description="The name of the server")
|
|
26
|
+
type: MCPServerType
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SSEServerConfig(BaseServerConfig):
|
|
30
|
+
type: MCPServerType = MCPServerType.SSE
|
|
31
|
+
server_url: str = Field(..., description="The URL of the server (MCP SSE client will connect to this URL)")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LocalServerConfig(BaseServerConfig):
|
|
35
|
+
type: MCPServerType = MCPServerType.LOCAL
|
|
36
|
+
command: str = Field(..., description="The command to run (MCP 'local' client will run this command)")
|
|
37
|
+
args: List[str] = Field(..., description="The arguments to pass to the command")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BaseMCPClient:
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self.session: Optional[ClientSession] = None
|
|
43
|
+
self.stdio = None
|
|
44
|
+
self.write = None
|
|
45
|
+
self.initialized = False
|
|
46
|
+
self.loop = asyncio.new_event_loop()
|
|
47
|
+
self.cleanup_funcs = []
|
|
48
|
+
|
|
49
|
+
def connect_to_server(self, server_config: BaseServerConfig):
|
|
50
|
+
asyncio.set_event_loop(self.loop)
|
|
51
|
+
self._initialize_connection(server_config)
|
|
52
|
+
self.loop.run_until_complete(self.session.initialize())
|
|
53
|
+
self.initialized = True
|
|
54
|
+
|
|
55
|
+
def _initialize_connection(self, server_config: BaseServerConfig):
|
|
56
|
+
raise NotImplementedError("Subclasses must implement _initialize_connection")
|
|
57
|
+
|
|
58
|
+
def list_tools(self) -> List[Tool]:
|
|
59
|
+
self._check_initialized()
|
|
60
|
+
response = self.loop.run_until_complete(self.session.list_tools())
|
|
61
|
+
return response.tools
|
|
62
|
+
|
|
63
|
+
def execute_tool(self, tool_name: str, tool_args: dict) -> Tuple[str, bool]:
|
|
64
|
+
self._check_initialized()
|
|
65
|
+
result = self.loop.run_until_complete(self.session.call_tool(tool_name, tool_args))
|
|
66
|
+
return str(result.content), result.isError
|
|
67
|
+
|
|
68
|
+
def _check_initialized(self):
|
|
69
|
+
if not self.initialized:
|
|
70
|
+
logger.error("MCPClient has not been initialized")
|
|
71
|
+
raise RuntimeError("MCPClient has not been initialized")
|
|
72
|
+
|
|
73
|
+
def cleanup(self):
|
|
74
|
+
try:
|
|
75
|
+
for cleanup_func in self.cleanup_funcs:
|
|
76
|
+
cleanup_func()
|
|
77
|
+
self.initialized = False
|
|
78
|
+
if not self.loop.is_closed():
|
|
79
|
+
self.loop.close()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.warning(e)
|
|
82
|
+
finally:
|
|
83
|
+
logger.info("Cleaned up MCP clients on shutdown.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LocalMCPClient(BaseMCPClient):
|
|
87
|
+
def _initialize_connection(self, server_config: LocalServerConfig):
|
|
88
|
+
server_params = StdioServerParameters(command=server_config.command, args=server_config.args)
|
|
89
|
+
stdio_cm = stdio_client(server_params)
|
|
90
|
+
stdio_transport = self.loop.run_until_complete(stdio_cm.__aenter__())
|
|
91
|
+
self.stdio, self.write = stdio_transport
|
|
92
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(stdio_cm.__aexit__(None, None, None)))
|
|
93
|
+
|
|
94
|
+
session_cm = ClientSession(self.stdio, self.write)
|
|
95
|
+
self.session = self.loop.run_until_complete(session_cm.__aenter__())
|
|
96
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(session_cm.__aexit__(None, None, None)))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SSEMCPClient(BaseMCPClient):
|
|
100
|
+
def _initialize_connection(self, server_config: SSEServerConfig):
|
|
101
|
+
sse_cm = sse_client(url=server_config.server_url)
|
|
102
|
+
sse_transport = self.loop.run_until_complete(sse_cm.__aenter__())
|
|
103
|
+
self.stdio, self.write = sse_transport
|
|
104
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(sse_cm.__aexit__(None, None, None)))
|
|
105
|
+
|
|
106
|
+
session_cm = ClientSession(self.stdio, self.write)
|
|
107
|
+
self.session = self.loop.run_until_complete(session_cm.__aenter__())
|
|
108
|
+
self.cleanup_funcs.append(lambda: self.loop.run_until_complete(session_cm.__aexit__(None, None, None)))
|
letta/llm_api/cohere.py
CHANGED
|
@@ -321,7 +321,7 @@ def cohere_chat_completions_request(
|
|
|
321
321
|
# See: https://docs.cohere.com/reference/chat
|
|
322
322
|
# The chat_history parameter should not be used for SYSTEM messages in most cases. Instead, to add a SYSTEM role message at the beginning of a conversation, the preamble parameter should be used.
|
|
323
323
|
assert msg_objs[0].role == "system", msg_objs[0]
|
|
324
|
-
preamble = msg_objs[0].text
|
|
324
|
+
preamble = msg_objs[0].content[0].text
|
|
325
325
|
|
|
326
326
|
# data["messages"] = [m.to_cohere_dict() for m in msg_objs[1:]]
|
|
327
327
|
data["messages"] = []
|