letta-nightly 0.6.38.dev20250312104155__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.

Files changed (41) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +49 -11
  3. letta/agents/low_latency_agent.py +3 -2
  4. letta/constants.py +3 -0
  5. letta/functions/function_sets/base.py +1 -1
  6. letta/functions/helpers.py +14 -0
  7. letta/functions/schema_generator.py +47 -0
  8. letta/helpers/mcp_helpers.py +108 -0
  9. letta/llm_api/cohere.py +1 -1
  10. letta/llm_api/helpers.py +1 -2
  11. letta/llm_api/llm_api_tools.py +0 -1
  12. letta/local_llm/utils.py +30 -20
  13. letta/log.py +1 -1
  14. letta/memory.py +1 -1
  15. letta/orm/__init__.py +1 -0
  16. letta/orm/block.py +8 -0
  17. letta/orm/enums.py +2 -0
  18. letta/orm/identities_blocks.py +13 -0
  19. letta/orm/identity.py +9 -0
  20. letta/orm/sqlalchemy_base.py +4 -4
  21. letta/schemas/identity.py +3 -0
  22. letta/schemas/message.py +68 -62
  23. letta/schemas/tool.py +39 -2
  24. letta/server/rest_api/app.py +15 -0
  25. letta/server/rest_api/chat_completions_interface.py +2 -0
  26. letta/server/rest_api/interface.py +46 -13
  27. letta/server/rest_api/routers/v1/agents.py +2 -2
  28. letta/server/rest_api/routers/v1/blocks.py +5 -1
  29. letta/server/rest_api/routers/v1/tools.py +71 -1
  30. letta/server/server.py +102 -5
  31. letta/services/agent_manager.py +2 -0
  32. letta/services/block_manager.py +10 -1
  33. letta/services/identity_manager.py +54 -14
  34. letta/services/summarizer/summarizer.py +1 -1
  35. letta/services/tool_manager.py +6 -0
  36. letta/settings.py +11 -12
  37. {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/METADATA +4 -3
  38. {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/RECORD +41 -39
  39. {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/LICENSE +0 -0
  40. {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/WHEEL +0 -0
  41. {letta_nightly-0.6.38.dev20250312104155.dist-info → letta_nightly-0.6.39.dev20250313104142.dist-info}/entry_points.txt +0 -0
letta/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.6.38"
1
+ __version__ = "0.6.39"
2
2
 
3
3
  # import clients
4
4
  from letta.client.client import LocalClient, RESTClient, create_client
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,6 +26,7 @@ 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
@@ -38,7 +39,7 @@ from letta.orm.enums import ToolType
38
39
  from letta.schemas.agent import AgentState, AgentStepResponse, UpdateAgent
39
40
  from letta.schemas.block import BlockUpdate
40
41
  from letta.schemas.embedding_config import EmbeddingConfig
41
- from letta.schemas.enums import MessageRole
42
+ from letta.schemas.enums import MessageContentType, MessageRole
42
43
  from letta.schemas.memory import ContextWindowOverview, Memory
43
44
  from letta.schemas.message import Message, ToolReturn
44
45
  from letta.schemas.openai.chat_completion_response import ChatCompletionResponse
@@ -92,6 +93,8 @@ class Agent(BaseAgent):
92
93
  user: User,
93
94
  # extras
94
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,
95
98
  ):
96
99
  assert isinstance(agent_state.memory, Memory), f"Memory object is not of type Memory: {type(agent_state.memory)}"
97
100
  # Hold a copy of the state that was used to init the agent
@@ -149,18 +152,22 @@ class Agent(BaseAgent):
149
152
  # Logger that the Agent specifically can use, will also report the agent_state ID with the logs
150
153
  self.logger = get_logger(agent_state.id)
151
154
 
155
+ # MCPClient, state/sessions managed by the server
156
+ self.mcp_clients = mcp_clients
157
+
152
158
  def load_last_function_response(self):
153
159
  """Load the last function response from message history"""
154
160
  in_context_messages = self.agent_manager.get_in_context_messages(agent_id=self.agent_state.id, actor=self.user)
155
161
  for i in range(len(in_context_messages) - 1, -1, -1):
156
162
  msg = in_context_messages[i]
157
- 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
158
165
  try:
159
- response_json = json.loads(msg.text)
166
+ response_json = json.loads(text_content)
160
167
  if response_json.get("message"):
161
168
  return response_json["message"]
162
169
  except (json.JSONDecodeError, KeyError):
163
- raise ValueError(f"Invalid JSON format in message: {msg.text}")
170
+ raise ValueError(f"Invalid JSON format in message: {text_content}")
164
171
  return None
165
172
 
166
173
  def update_memory_if_changed(self, new_memory: Memory) -> bool:
@@ -197,6 +204,7 @@ class Agent(BaseAgent):
197
204
  return True
198
205
  return False
199
206
 
207
+ # TODO: Refactor into separate class v.s. large if/elses here
200
208
  def execute_tool_and_persist_state(
201
209
  self, function_name: str, function_args: dict, target_letta_tool: Tool
202
210
  ) -> tuple[Any, Optional[SandboxRunResult]]:
@@ -237,6 +245,32 @@ class Agent(BaseAgent):
237
245
  function_response = execute_composio_action(
238
246
  action_name=action_name, args=function_args, api_key=composio_api_key, entity_id=entity_id
239
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
240
274
  else:
241
275
  try:
242
276
  # Parse the source code to extract function annotations
@@ -267,6 +301,7 @@ class Agent(BaseAgent):
267
301
  function_response = get_friendly_error_msg(
268
302
  function_name=function_name, exception_name=type(e).__name__, exception_message=str(e)
269
303
  )
304
+ return function_response, SandboxRunResult(status="error")
270
305
 
271
306
  return function_response, None
272
307
 
@@ -1010,7 +1045,7 @@ class Agent(BaseAgent):
1010
1045
  err_msg,
1011
1046
  details={
1012
1047
  "num_in_context_messages": len(self.agent_state.message_ids),
1013
- "in_context_messages_text": [m.text for m in in_context_messages],
1048
+ "in_context_messages_text": [m.content for m in in_context_messages],
1014
1049
  "token_counts": token_counts,
1015
1050
  },
1016
1051
  )
@@ -1164,14 +1199,17 @@ class Agent(BaseAgent):
1164
1199
  if (
1165
1200
  len(in_context_messages) > 1
1166
1201
  and in_context_messages[1].role == MessageRole.user
1167
- and isinstance(in_context_messages[1].text, str)
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
1168
1205
  # TODO remove hardcoding
1169
- 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
1170
1207
  ):
1171
1208
  # Summary message exists
1172
- assert in_context_messages[1].text is not None
1173
- summary_memory = in_context_messages[1].text
1174
- num_tokens_summary_memory = count_tokens(in_context_messages[1].text)
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)
1175
1213
  # with a summary message, the real messages start at index 2
1176
1214
  num_tokens_messages = (
1177
1215
  num_tokens_from_messages(messages=in_context_messages_openai[2:], model=self.model)
@@ -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
- if curr_memory_str in curr_system_message.text:
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(curr_system_message.text, new_system_message_str)
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/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
 
@@ -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
 
@@ -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)
@@ -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"] = []
letta/llm_api/helpers.py CHANGED
@@ -86,9 +86,8 @@ def convert_to_structured_output(openai_function: dict, allow_optional: bool = F
86
86
  # but if "type" is "object" we expected "properties", where each property has details
87
87
  # and if "type" is "array" we expect "items": <type>
88
88
  for param, details in openai_function["parameters"]["properties"].items():
89
-
90
89
  param_type = details["type"]
91
- description = details["description"]
90
+ description = details.get("description", "")
92
91
 
93
92
  if param_type == "object":
94
93
  if "properties" not in details:
@@ -596,7 +596,6 @@ def create(
596
596
  messages[0].content[
597
597
  0
598
598
  ].text += f'Select best function to call simply by responding with a single json block with the keys "function" and "params". Use double quotes around the arguments.'
599
-
600
599
  return get_chat_completion(
601
600
  model=llm_config.model,
602
601
  messages=messages,
letta/local_llm/utils.py CHANGED
@@ -114,26 +114,36 @@ def num_tokens_from_functions(functions: List[dict], model: str = "gpt-4"):
114
114
  function_tokens += len(encoding.encode(propertiesKey))
115
115
  v = parameters["properties"][propertiesKey]
116
116
  for field in v:
117
- if field == "type":
118
- function_tokens += 2
119
- function_tokens += len(encoding.encode(v["type"]))
120
- elif field == "description":
121
- function_tokens += 2
122
- function_tokens += len(encoding.encode(v["description"]))
123
- elif field == "enum":
124
- function_tokens -= 3
125
- for o in v["enum"]:
126
- function_tokens += 3
127
- function_tokens += len(encoding.encode(o))
128
- elif field == "items":
129
- function_tokens += 2
130
- if isinstance(v["items"], dict) and "type" in v["items"]:
131
- function_tokens += len(encoding.encode(v["items"]["type"]))
132
- elif field == "default":
133
- function_tokens += 2
134
- function_tokens += len(encoding.encode(str(v["default"])))
135
- else:
136
- logger.warning(f"num_tokens_from_functions: Unsupported field {field} in function {function}")
117
+ try:
118
+ if field == "type":
119
+ function_tokens += 2
120
+ function_tokens += len(encoding.encode(v["type"]))
121
+ elif field == "description":
122
+ function_tokens += 2
123
+ function_tokens += len(encoding.encode(v["description"]))
124
+ elif field == "enum":
125
+ function_tokens -= 3
126
+ for o in v["enum"]:
127
+ function_tokens += 3
128
+ function_tokens += len(encoding.encode(o))
129
+ elif field == "items":
130
+ function_tokens += 2
131
+ if isinstance(v["items"], dict) and "type" in v["items"]:
132
+ function_tokens += len(encoding.encode(v["items"]["type"]))
133
+ elif field == "default":
134
+ function_tokens += 2
135
+ function_tokens += len(encoding.encode(str(v["default"])))
136
+ elif field == "title":
137
+ # TODO: Is this right? For MCP
138
+ continue
139
+ else:
140
+ # TODO: Handle nesting here properly
141
+ # Disable this for now for MCP
142
+ continue
143
+ # logger.warning(f"num_tokens_from_functions: Unsupported field {field} in function {function}")
144
+ except:
145
+ logger.error(f"Failed to encode field {field} with value {v}")
146
+ raise
137
147
  function_tokens += 11
138
148
 
139
149
  num_tokens += function_tokens
letta/log.py CHANGED
@@ -54,7 +54,7 @@ DEVELOPMENT_LOGGING = {
54
54
  "propagate": True, # Let logs bubble up to root
55
55
  },
56
56
  "uvicorn": {
57
- "level": "INFO",
57
+ "level": "CRITICAL",
58
58
  "handlers": ["console"],
59
59
  "propagate": True,
60
60
  },
letta/memory.py CHANGED
@@ -36,7 +36,7 @@ def get_memory_functions(cls: Memory) -> Dict[str, Callable]:
36
36
 
37
37
  def _format_summary_history(message_history: List[Message]):
38
38
  # TODO use existing prompt formatters for this (eg ChatML)
39
- return "\n".join([f"{m.role}: {m.text}" for m in message_history])
39
+ return "\n".join([f"{m.role}: {m.content[0].text}" for m in message_history])
40
40
 
41
41
 
42
42
  def summarize_messages(
letta/orm/__init__.py CHANGED
@@ -5,6 +5,7 @@ from letta.orm.block import Block
5
5
  from letta.orm.blocks_agents import BlocksAgents
6
6
  from letta.orm.file import FileMetadata
7
7
  from letta.orm.identities_agents import IdentitiesAgents
8
+ from letta.orm.identities_blocks import IdentitiesBlocks
8
9
  from letta.orm.identity import Identity
9
10
  from letta.orm.job import Job
10
11
  from letta.orm.job_messages import JobMessage
letta/orm/block.py CHANGED
@@ -12,6 +12,7 @@ from letta.schemas.block import Human, Persona
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from letta.orm import Organization
15
+ from letta.orm.identity import Identity
15
16
 
16
17
 
17
18
  class Block(OrganizationMixin, SqlalchemyBase):
@@ -47,6 +48,13 @@ class Block(OrganizationMixin, SqlalchemyBase):
47
48
  back_populates="core_memory",
48
49
  doc="Agents associated with this block.",
49
50
  )
51
+ identities: Mapped[List["Identity"]] = relationship(
52
+ "Identity",
53
+ secondary="identities_blocks",
54
+ lazy="selectin",
55
+ back_populates="blocks",
56
+ passive_deletes=True,
57
+ )
50
58
 
51
59
  def to_pydantic(self) -> Type:
52
60
  match self.label:
letta/orm/enums.py CHANGED
@@ -8,6 +8,8 @@ class ToolType(str, Enum):
8
8
  LETTA_MULTI_AGENT_CORE = "letta_multi_agent_core"
9
9
  EXTERNAL_COMPOSIO = "external_composio"
10
10
  EXTERNAL_LANGCHAIN = "external_langchain"
11
+ # TODO is "external" the right name here? Since as of now, MCP is local / doesn't support remote?
12
+ EXTERNAL_MCP = "external_mcp"
11
13
 
12
14
 
13
15
  class JobType(str, Enum):
@@ -0,0 +1,13 @@
1
+ from sqlalchemy import ForeignKey, String
2
+ from sqlalchemy.orm import Mapped, mapped_column
3
+
4
+ from letta.orm.base import Base
5
+
6
+
7
+ class IdentitiesBlocks(Base):
8
+ """Identities may have one or many blocks associated with them."""
9
+
10
+ __tablename__ = "identities_blocks"
11
+
12
+ identity_id: Mapped[str] = mapped_column(String, ForeignKey("identities.id", ondelete="CASCADE"), primary_key=True)
13
+ block_id: Mapped[str] = mapped_column(String, ForeignKey("block.id", ondelete="CASCADE"), primary_key=True)
letta/orm/identity.py CHANGED
@@ -40,12 +40,20 @@ class Identity(SqlalchemyBase, OrganizationMixin):
40
40
  agents: Mapped[List["Agent"]] = relationship(
41
41
  "Agent", secondary="identities_agents", lazy="selectin", passive_deletes=True, back_populates="identities"
42
42
  )
43
+ blocks: Mapped[List["Block"]] = relationship(
44
+ "Block", secondary="identities_blocks", lazy="selectin", passive_deletes=True, back_populates="identities"
45
+ )
43
46
 
44
47
  @property
45
48
  def agent_ids(self) -> List[str]:
46
49
  """Get just the agent IDs without loading the full agent objects"""
47
50
  return [agent.id for agent in self.agents]
48
51
 
52
+ @property
53
+ def block_ids(self) -> List[str]:
54
+ """Get just the block IDs without loading the full agent objects"""
55
+ return [block.id for block in self.blocks]
56
+
49
57
  def to_pydantic(self) -> PydanticIdentity:
50
58
  state = {
51
59
  "id": self.id,
@@ -54,6 +62,7 @@ class Identity(SqlalchemyBase, OrganizationMixin):
54
62
  "identity_type": self.identity_type,
55
63
  "project_id": self.project_id,
56
64
  "agent_ids": self.agent_ids,
65
+ "block_ids": self.block_ids,
57
66
  "organization_id": self.organization_id,
58
67
  "properties": self.properties,
59
68
  }
@@ -69,7 +69,7 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
69
69
  join_model: Optional[Base] = None,
70
70
  join_conditions: Optional[Union[Tuple, List]] = None,
71
71
  identifier_keys: Optional[List[str]] = None,
72
- identifier_id: Optional[str] = None,
72
+ identity_id: Optional[str] = None,
73
73
  **kwargs,
74
74
  ) -> List["SqlalchemyBase"]:
75
75
  """
@@ -148,9 +148,9 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
148
148
  if identifier_keys and hasattr(cls, "identities"):
149
149
  query = query.join(cls.identities).filter(cls.identities.property.mapper.class_.identifier_key.in_(identifier_keys))
150
150
 
151
- # given the identifier_id, we can find within the agents table any agents that have the identifier_id in their identity_ids
152
- if identifier_id and hasattr(cls, "identities"):
153
- query = query.join(cls.identities).filter(cls.identities.property.mapper.class_.id == identifier_id)
151
+ # given the identity_id, we can find within the agents table any agents that have the identity_id in their identity_ids
152
+ if identity_id and hasattr(cls, "identities"):
153
+ query = query.join(cls.identities).filter(cls.identities.property.mapper.class_.id == identity_id)
154
154
 
155
155
  # Apply filtering logic from kwargs
156
156
  for key, value in kwargs.items():
letta/schemas/identity.py CHANGED
@@ -46,6 +46,7 @@ class Identity(IdentityBase):
46
46
  identity_type: IdentityType = Field(..., description="The type of the identity.")
47
47
  project_id: Optional[str] = Field(None, description="The project id of the identity, if applicable.")
48
48
  agent_ids: List[str] = Field(..., description="The IDs of the agents associated with the identity.")
49
+ block_ids: List[str] = Field(..., description="The IDs of the blocks associated with the identity.")
49
50
  organization_id: Optional[str] = Field(None, description="The organization id of the user")
50
51
  properties: List[IdentityProperty] = Field(default_factory=list, description="List of properties associated with the identity")
51
52
 
@@ -56,6 +57,7 @@ class IdentityCreate(LettaBase):
56
57
  identity_type: IdentityType = Field(..., description="The type of the identity.")
57
58
  project_id: Optional[str] = Field(None, description="The project id of the identity, if applicable.")
58
59
  agent_ids: Optional[List[str]] = Field(None, description="The agent ids that are associated with the identity.")
60
+ block_ids: Optional[List[str]] = Field(None, description="The IDs of the blocks associated with the identity.")
59
61
  properties: Optional[List[IdentityProperty]] = Field(None, description="List of properties associated with the identity.")
60
62
 
61
63
 
@@ -64,4 +66,5 @@ class IdentityUpdate(LettaBase):
64
66
  name: Optional[str] = Field(None, description="The name of the identity.")
65
67
  identity_type: Optional[IdentityType] = Field(None, description="The type of the identity.")
66
68
  agent_ids: Optional[List[str]] = Field(None, description="The agent ids that are associated with the identity.")
69
+ block_ids: Optional[List[str]] = Field(None, description="The IDs of the blocks associated with the identity.")
67
70
  properties: Optional[List[IdentityProperty]] = Field(None, description="List of properties associated with the identity.")