letta-nightly 0.7.16.dev20250515205957__py3-none-any.whl → 0.7.17.dev20250516104241__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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "0.7.16"
1
+ __version__ = "0.7.17"
2
2
 
3
3
  # import clients
4
4
  from letta.client.client import LocalClient, RESTClient, create_client
@@ -8,10 +8,11 @@ from openai.types import CompletionUsage
8
8
  from openai.types.chat import ChatCompletion, ChatCompletionChunk
9
9
 
10
10
  from letta.agents.base_agent import BaseAgent
11
- from letta.agents.helpers import _create_letta_response, _prepare_in_context_messages
11
+ from letta.agents.helpers import _create_letta_response, _prepare_in_context_messages_async
12
12
  from letta.helpers import ToolRulesSolver
13
13
  from letta.helpers.tool_execution_helper import enable_strict_mode
14
14
  from letta.interfaces.anthropic_streaming_interface import AnthropicStreamingInterface
15
+ from letta.interfaces.openai_streaming_interface import OpenAIStreamingInterface
15
16
  from letta.llm_api.llm_client import LLMClient
16
17
  from letta.llm_api.llm_client_base import LLMClientBase
17
18
  from letta.local_llm.constants import INNER_THOUGHTS_KWARG
@@ -61,12 +62,8 @@ class LettaAgent(BaseAgent):
61
62
  self.last_function_response = None
62
63
 
63
64
  # Cached archival memory/message size
64
- self.num_messages = self.message_manager.size(actor=self.actor, agent_id=agent_id)
65
- self.num_archival_memories = self.passage_manager.size(actor=self.actor, agent_id=agent_id)
66
-
67
- # Cached archival memory/message size
68
- self.num_messages = self.message_manager.size(actor=self.actor, agent_id=agent_id)
69
- self.num_archival_memories = self.passage_manager.size(actor=self.actor, agent_id=agent_id)
65
+ self.num_messages = 0
66
+ self.num_archival_memories = 0
70
67
 
71
68
  @trace_method
72
69
  async def step(self, input_messages: List[MessageCreate], max_steps: int = 10, use_assistant_message: bool = True) -> LettaResponse:
@@ -81,7 +78,7 @@ class LettaAgent(BaseAgent):
81
78
  async def _step(
82
79
  self, agent_state: AgentState, input_messages: List[MessageCreate], max_steps: int = 10
83
80
  ) -> Tuple[List[Message], List[Message], CompletionUsage]:
84
- current_in_context_messages, new_in_context_messages = _prepare_in_context_messages(
81
+ current_in_context_messages, new_in_context_messages = await _prepare_in_context_messages_async(
85
82
  input_messages, agent_state, self.message_manager, self.actor
86
83
  )
87
84
  tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
@@ -129,14 +126,14 @@ class LettaAgent(BaseAgent):
129
126
 
130
127
  @trace_method
131
128
  async def step_stream(
132
- self, input_messages: List[MessageCreate], max_steps: int = 10, use_assistant_message: bool = True
129
+ self, input_messages: List[MessageCreate], max_steps: int = 10, use_assistant_message: bool = True, stream_tokens: bool = False
133
130
  ) -> AsyncGenerator[str, None]:
134
131
  """
135
132
  Main streaming loop that yields partial tokens.
136
133
  Whenever we detect a tool call, we yield from _handle_ai_response as well.
137
134
  """
138
135
  agent_state = await self.agent_manager.get_agent_by_id_async(self.agent_id, actor=self.actor)
139
- current_in_context_messages, new_in_context_messages = _prepare_in_context_messages(
136
+ current_in_context_messages, new_in_context_messages = await _prepare_in_context_messages_async(
140
137
  input_messages, agent_state, self.message_manager, self.actor
141
138
  )
142
139
  tool_rules_solver = ToolRulesSolver(agent_state.tool_rules)
@@ -157,9 +154,16 @@ class LettaAgent(BaseAgent):
157
154
  )
158
155
  # TODO: THIS IS INCREDIBLY UGLY
159
156
  # TODO: THERE ARE MULTIPLE COPIES OF THE LLM_CONFIG EVERYWHERE THAT ARE GETTING MANIPULATED
160
- interface = AnthropicStreamingInterface(
161
- use_assistant_message=use_assistant_message, put_inner_thoughts_in_kwarg=agent_state.llm_config.put_inner_thoughts_in_kwargs
162
- )
157
+ if agent_state.llm_config.model_endpoint_type == "anthropic":
158
+ interface = AnthropicStreamingInterface(
159
+ use_assistant_message=use_assistant_message,
160
+ put_inner_thoughts_in_kwarg=agent_state.llm_config.put_inner_thoughts_in_kwargs,
161
+ )
162
+ elif agent_state.llm_config.model_endpoint_type == "openai":
163
+ interface = OpenAIStreamingInterface(
164
+ use_assistant_message=use_assistant_message,
165
+ put_inner_thoughts_in_kwarg=agent_state.llm_config.put_inner_thoughts_in_kwargs,
166
+ )
163
167
  async for chunk in interface.process(stream):
164
168
  yield f"data: {chunk.model_dump_json()}\n\n"
165
169
 
@@ -197,8 +201,8 @@ class LettaAgent(BaseAgent):
197
201
 
198
202
  # TODO: This may be out of sync, if in between steps users add files
199
203
  # NOTE (cliandy): temporary for now for particlar use cases.
200
- self.num_messages = self.message_manager.size(actor=self.actor, agent_id=agent_state.id)
201
- self.num_archival_memories = self.passage_manager.size(actor=self.actor, agent_id=agent_state.id)
204
+ self.num_messages = await self.message_manager.size_async(actor=self.actor, agent_id=agent_state.id)
205
+ self.num_archival_memories = await self.passage_manager.size_async(actor=self.actor, agent_id=agent_state.id)
202
206
 
203
207
  # TODO: Also yield out a letta usage stats SSE
204
208
  yield f"data: {usage.model_dump_json()}\n\n"
@@ -215,6 +219,10 @@ class LettaAgent(BaseAgent):
215
219
  stream: bool,
216
220
  ) -> ChatCompletion | AsyncStream[ChatCompletionChunk]:
217
221
  if settings.experimental_enable_async_db_engine:
222
+ self.num_messages = self.num_messages or (await self.message_manager.size_async(actor=self.actor, agent_id=agent_state.id))
223
+ self.num_archival_memories = self.num_archival_memories or (
224
+ await self.passage_manager.size_async(actor=self.actor, agent_id=agent_state.id)
225
+ )
218
226
  in_context_messages = await self._rebuild_memory_async(
219
227
  in_context_messages, agent_state, num_messages=self.num_messages, num_archival_memories=self.num_archival_memories
220
228
  )
@@ -0,0 +1,303 @@
1
+ from datetime import datetime, timezone
2
+ from typing import AsyncGenerator, List, Optional
3
+
4
+ from openai import AsyncStream
5
+ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
6
+
7
+ from letta.constants import DEFAULT_MESSAGE_TOOL, DEFAULT_MESSAGE_TOOL_KWARG
8
+ from letta.schemas.letta_message import AssistantMessage, LettaMessage, ReasoningMessage, ToolCallDelta, ToolCallMessage
9
+ from letta.schemas.letta_message_content import TextContent
10
+ from letta.schemas.message import Message
11
+ from letta.schemas.openai.chat_completion_response import FunctionCall, ToolCall
12
+ from letta.server.rest_api.json_parser import OptimisticJSONParser
13
+ from letta.streaming_utils import JSONInnerThoughtsExtractor
14
+
15
+
16
+ class OpenAIStreamingInterface:
17
+ """
18
+ Encapsulates the logic for streaming responses from OpenAI.
19
+ This class handles parsing of partial tokens, pre-execution messages,
20
+ and detection of tool call events.
21
+ """
22
+
23
+ def __init__(self, use_assistant_message: bool = False, put_inner_thoughts_in_kwarg: bool = False):
24
+ self.use_assistant_message = use_assistant_message
25
+ self.assistant_message_tool_name = DEFAULT_MESSAGE_TOOL
26
+ self.assistant_message_tool_kwarg = DEFAULT_MESSAGE_TOOL_KWARG
27
+
28
+ self.optimistic_json_parser: OptimisticJSONParser = OptimisticJSONParser()
29
+ self.function_args_reader = JSONInnerThoughtsExtractor(wait_for_first_key=True) # TODO: pass in kward
30
+ self.function_name_buffer = None
31
+ self.function_args_buffer = None
32
+ self.function_id_buffer = None
33
+ self.last_flushed_function_name = None
34
+
35
+ # Buffer to hold function arguments until inner thoughts are complete
36
+ self.current_function_arguments = ""
37
+ self.current_json_parse_result = {}
38
+
39
+ # Premake IDs for database writes
40
+ self.letta_assistant_message_id = Message.generate_id()
41
+ self.letta_tool_message_id = Message.generate_id()
42
+
43
+ # token counters
44
+ self.input_tokens = 0
45
+ self.output_tokens = 0
46
+
47
+ self.content_buffer: List[str] = []
48
+ self.tool_call_name: Optional[str] = None
49
+ self.tool_call_id: Optional[str] = None
50
+ self.reasoning_messages = []
51
+
52
+ def get_reasoning_content(self) -> List[TextContent]:
53
+ content = "".join(self.reasoning_messages)
54
+ return [TextContent(text=content)]
55
+
56
+ def get_tool_call_object(self) -> ToolCall:
57
+ """Useful for agent loop"""
58
+ return ToolCall(
59
+ id=self.letta_tool_message_id,
60
+ function=FunctionCall(arguments=self.current_function_arguments, name=self.last_flushed_function_name),
61
+ )
62
+
63
+ async def process(self, stream: AsyncStream[ChatCompletionChunk]) -> AsyncGenerator[LettaMessage, None]:
64
+ """
65
+ Iterates over the OpenAI stream, yielding SSE events.
66
+ It also collects tokens and detects if a tool call is triggered.
67
+ """
68
+ async with stream:
69
+ prev_message_type = None
70
+ message_index = 0
71
+ async for chunk in stream:
72
+ # track usage
73
+ if chunk.usage:
74
+ self.input_tokens += len(chunk.usage.prompt_tokens)
75
+ self.output_tokens += len(chunk.usage.completion_tokens)
76
+
77
+ if chunk.choices:
78
+ choice = chunk.choices[0]
79
+ message_delta = choice.delta
80
+
81
+ if message_delta.tool_calls is not None and len(message_delta.tool_calls) > 0:
82
+ tool_call = message_delta.tool_calls[0]
83
+
84
+ if tool_call.function.name:
85
+ # If we're waiting for the first key, then we should hold back the name
86
+ # ie add it to a buffer instead of returning it as a chunk
87
+ if self.function_name_buffer is None:
88
+ self.function_name_buffer = tool_call.function.name
89
+ else:
90
+ self.function_name_buffer += tool_call.function.name
91
+
92
+ if tool_call.id:
93
+ # Buffer until next time
94
+ if self.function_id_buffer is None:
95
+ self.function_id_buffer = tool_call.id
96
+ else:
97
+ self.function_id_buffer += tool_call.id
98
+
99
+ if tool_call.function.arguments:
100
+ # updates_main_json, updates_inner_thoughts = self.function_args_reader.process_fragment(tool_call.function.arguments)
101
+ self.current_function_arguments += tool_call.function.arguments
102
+ updates_main_json, updates_inner_thoughts = self.function_args_reader.process_fragment(
103
+ tool_call.function.arguments
104
+ )
105
+
106
+ # If we have inner thoughts, we should output them as a chunk
107
+ if updates_inner_thoughts:
108
+ if prev_message_type and prev_message_type != "reasoning_message":
109
+ message_index += 1
110
+ self.reasoning_messages.append(updates_inner_thoughts)
111
+ reasoning_message = ReasoningMessage(
112
+ id=self.letta_tool_message_id,
113
+ date=datetime.now(timezone.utc),
114
+ reasoning=updates_inner_thoughts,
115
+ # name=name,
116
+ otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index),
117
+ )
118
+ prev_message_type = reasoning_message.message_type
119
+ yield reasoning_message
120
+
121
+ # Additionally inner thoughts may stream back with a chunk of main JSON
122
+ # In that case, since we can only return a chunk at a time, we should buffer it
123
+ if updates_main_json:
124
+ if self.function_args_buffer is None:
125
+ self.function_args_buffer = updates_main_json
126
+ else:
127
+ self.function_args_buffer += updates_main_json
128
+
129
+ # If we have main_json, we should output a ToolCallMessage
130
+ elif updates_main_json:
131
+
132
+ # If there's something in the function_name buffer, we should release it first
133
+ # NOTE: we could output it as part of a chunk that has both name and args,
134
+ # however the frontend may expect name first, then args, so to be
135
+ # safe we'll output name first in a separate chunk
136
+ if self.function_name_buffer:
137
+
138
+ # use_assisitant_message means that we should also not release main_json raw, and instead should only release the contents of "message": "..."
139
+ if self.use_assistant_message and self.function_name_buffer == self.assistant_message_tool_name:
140
+
141
+ # Store the ID of the tool call so allow skipping the corresponding response
142
+ if self.function_id_buffer:
143
+ self.prev_assistant_message_id = self.function_id_buffer
144
+
145
+ else:
146
+ if prev_message_type and prev_message_type != "tool_call_message":
147
+ message_index += 1
148
+ self.tool_call_name = str(self.function_name_buffer)
149
+ tool_call_msg = ToolCallMessage(
150
+ id=self.letta_tool_message_id,
151
+ date=datetime.now(timezone.utc),
152
+ tool_call=ToolCallDelta(
153
+ name=self.function_name_buffer,
154
+ arguments=None,
155
+ tool_call_id=self.function_id_buffer,
156
+ ),
157
+ otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index),
158
+ )
159
+ prev_message_type = tool_call_msg.message_type
160
+ yield tool_call_msg
161
+
162
+ # Record what the last function name we flushed was
163
+ self.last_flushed_function_name = self.function_name_buffer
164
+ # Clear the buffer
165
+ self.function_name_buffer = None
166
+ self.function_id_buffer = None
167
+ # Since we're clearing the name buffer, we should store
168
+ # any updates to the arguments inside a separate buffer
169
+
170
+ # Add any main_json updates to the arguments buffer
171
+ if self.function_args_buffer is None:
172
+ self.function_args_buffer = updates_main_json
173
+ else:
174
+ self.function_args_buffer += updates_main_json
175
+
176
+ # If there was nothing in the name buffer, we can proceed to
177
+ # output the arguments chunk as a ToolCallMessage
178
+ else:
179
+
180
+ # use_assisitant_message means that we should also not release main_json raw, and instead should only release the contents of "message": "..."
181
+ if self.use_assistant_message and (
182
+ self.last_flushed_function_name is not None
183
+ and self.last_flushed_function_name == self.assistant_message_tool_name
184
+ ):
185
+ # do an additional parse on the updates_main_json
186
+ if self.function_args_buffer:
187
+ updates_main_json = self.function_args_buffer + updates_main_json
188
+ self.function_args_buffer = None
189
+
190
+ # Pretty gross hardcoding that assumes that if we're toggling into the keywords, we have the full prefix
191
+ match_str = '{"' + self.assistant_message_tool_kwarg + '":"'
192
+ if updates_main_json == match_str:
193
+ updates_main_json = None
194
+
195
+ else:
196
+ # Some hardcoding to strip off the trailing "}"
197
+ if updates_main_json in ["}", '"}']:
198
+ updates_main_json = None
199
+ if updates_main_json and len(updates_main_json) > 0 and updates_main_json[-1:] == '"':
200
+ updates_main_json = updates_main_json[:-1]
201
+
202
+ if not updates_main_json:
203
+ # early exit to turn into content mode
204
+ continue
205
+
206
+ # There may be a buffer from a previous chunk, for example
207
+ # if the previous chunk had arguments but we needed to flush name
208
+ if self.function_args_buffer:
209
+ # In this case, we should release the buffer + new data at once
210
+ combined_chunk = self.function_args_buffer + updates_main_json
211
+
212
+ if prev_message_type and prev_message_type != "assistant_message":
213
+ message_index += 1
214
+ assistant_message = AssistantMessage(
215
+ id=self.letta_assistant_message_id,
216
+ date=datetime.now(timezone.utc),
217
+ content=combined_chunk,
218
+ otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index),
219
+ )
220
+ prev_message_type = assistant_message.message_type
221
+ yield assistant_message
222
+ # Store the ID of the tool call so allow skipping the corresponding response
223
+ if self.function_id_buffer:
224
+ self.prev_assistant_message_id = self.function_id_buffer
225
+ # clear buffer
226
+ self.function_args_buffer = None
227
+ self.function_id_buffer = None
228
+
229
+ else:
230
+ # If there's no buffer to clear, just output a new chunk with new data
231
+ # TODO: THIS IS HORRIBLE
232
+ # TODO: WE USE THE OLD JSON PARSER EARLIER (WHICH DOES NOTHING) AND NOW THE NEW JSON PARSER
233
+ # TODO: THIS IS TOTALLY WRONG AND BAD, BUT SAVING FOR A LARGER REWRITE IN THE NEAR FUTURE
234
+ parsed_args = self.optimistic_json_parser.parse(self.current_function_arguments)
235
+
236
+ if parsed_args.get(self.assistant_message_tool_kwarg) and parsed_args.get(
237
+ self.assistant_message_tool_kwarg
238
+ ) != self.current_json_parse_result.get(self.assistant_message_tool_kwarg):
239
+ new_content = parsed_args.get(self.assistant_message_tool_kwarg)
240
+ prev_content = self.current_json_parse_result.get(self.assistant_message_tool_kwarg, "")
241
+ # TODO: Assumes consistent state and that prev_content is subset of new_content
242
+ diff = new_content.replace(prev_content, "", 1)
243
+ self.current_json_parse_result = parsed_args
244
+ if prev_message_type and prev_message_type != "assistant_message":
245
+ message_index += 1
246
+ assistant_message = AssistantMessage(
247
+ id=self.letta_assistant_message_id,
248
+ date=datetime.now(timezone.utc),
249
+ content=diff,
250
+ # name=name,
251
+ otid=Message.generate_otid_from_id(self.letta_assistant_message_id, message_index),
252
+ )
253
+ prev_message_type = assistant_message.message_type
254
+ yield assistant_message
255
+
256
+ # Store the ID of the tool call so allow skipping the corresponding response
257
+ if self.function_id_buffer:
258
+ self.prev_assistant_message_id = self.function_id_buffer
259
+ # clear buffers
260
+ self.function_id_buffer = None
261
+ else:
262
+
263
+ # There may be a buffer from a previous chunk, for example
264
+ # if the previous chunk had arguments but we needed to flush name
265
+ if self.function_args_buffer:
266
+ # In this case, we should release the buffer + new data at once
267
+ combined_chunk = self.function_args_buffer + updates_main_json
268
+ if prev_message_type and prev_message_type != "tool_call_message":
269
+ message_index += 1
270
+ tool_call_msg = ToolCallMessage(
271
+ id=self.letta_tool_message_id,
272
+ date=datetime.now(timezone.utc),
273
+ tool_call=ToolCallDelta(
274
+ name=None,
275
+ arguments=combined_chunk,
276
+ tool_call_id=self.function_id_buffer,
277
+ ),
278
+ # name=name,
279
+ otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index),
280
+ )
281
+ prev_message_type = tool_call_msg.message_type
282
+ yield tool_call_msg
283
+ # clear buffer
284
+ self.function_args_buffer = None
285
+ self.function_id_buffer = None
286
+ else:
287
+ # If there's no buffer to clear, just output a new chunk with new data
288
+ if prev_message_type and prev_message_type != "tool_call_message":
289
+ message_index += 1
290
+ tool_call_msg = ToolCallMessage(
291
+ id=self.letta_tool_message_id,
292
+ date=datetime.now(timezone.utc),
293
+ tool_call=ToolCallDelta(
294
+ name=None,
295
+ arguments=updates_main_json,
296
+ tool_call_id=self.function_id_buffer,
297
+ ),
298
+ # name=name,
299
+ otid=Message.generate_otid_from_id(self.letta_tool_message_id, message_index),
300
+ )
301
+ prev_message_type = tool_call_msg.message_type
302
+ yield tool_call_msg
303
+ self.function_id_buffer = None
@@ -745,6 +745,17 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
745
745
  self.is_deleted = True
746
746
  return self.update(db_session)
747
747
 
748
+ @handle_db_timeout
749
+ async def delete_async(self, db_session: "AsyncSession", actor: Optional["User"] = None) -> "SqlalchemyBase":
750
+ """Soft delete a record asynchronously (mark as deleted)."""
751
+ logger.debug(f"Soft deleting {self.__class__.__name__} with ID: {self.id} with actor={actor} (async)")
752
+
753
+ if actor:
754
+ self._set_created_and_updated_by_fields(actor.id)
755
+
756
+ self.is_deleted = True
757
+ return await self.update_async(db_session)
758
+
748
759
  @handle_db_timeout
749
760
  def hard_delete(self, db_session: "Session", actor: Optional["User"] = None) -> None:
750
761
  """Permanently removes the record from the database."""
@@ -761,6 +772,20 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
761
772
  else:
762
773
  logger.debug(f"{self.__class__.__name__} with ID {self.id} successfully hard deleted")
763
774
 
775
+ @handle_db_timeout
776
+ async def hard_delete_async(self, db_session: "AsyncSession", actor: Optional["User"] = None) -> None:
777
+ """Permanently removes the record from the database asynchronously."""
778
+ logger.debug(f"Hard deleting {self.__class__.__name__} with ID: {self.id} with actor={actor} (async)")
779
+
780
+ async with db_session as session:
781
+ try:
782
+ await session.delete(self)
783
+ await session.commit()
784
+ except Exception as e:
785
+ await session.rollback()
786
+ logger.exception(f"Failed to hard delete {self.__class__.__name__} with ID {self.id}")
787
+ raise ValueError(f"Failed to hard delete {self.__class__.__name__} with ID {self.id}: {e}")
788
+
764
789
  @handle_db_timeout
765
790
  def update(self, db_session: Session, actor: Optional["User"] = None, no_commit: bool = False) -> "SqlalchemyBase":
766
791
  logger.debug(...)
@@ -793,6 +818,39 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
793
818
  await db_session.refresh(self)
794
819
  return self
795
820
 
821
+ @classmethod
822
+ def _size_preprocess(
823
+ cls,
824
+ *,
825
+ db_session: "Session",
826
+ actor: Optional["User"] = None,
827
+ access: Optional[List[Literal["read", "write", "admin"]]] = ["read"],
828
+ access_type: AccessType = AccessType.ORGANIZATION,
829
+ **kwargs,
830
+ ):
831
+ logger.debug(f"Calculating size for {cls.__name__} with filters {kwargs}")
832
+ query = select(func.count()).select_from(cls)
833
+
834
+ if actor:
835
+ query = cls.apply_access_predicate(query, actor, access, access_type)
836
+
837
+ # Apply filtering logic based on kwargs
838
+ for key, value in kwargs.items():
839
+ if value:
840
+ column = getattr(cls, key, None)
841
+ if not column:
842
+ raise AttributeError(f"{cls.__name__} has no attribute '{key}'")
843
+ if isinstance(value, (list, tuple, set)): # Check for iterables
844
+ query = query.where(column.in_(value))
845
+ else: # Single value for equality filtering
846
+ query = query.where(column == value)
847
+
848
+ # Handle soft deletes if the class has the 'is_deleted' attribute
849
+ if hasattr(cls, "is_deleted"):
850
+ query = query.where(cls.is_deleted == False)
851
+
852
+ return query
853
+
796
854
  @classmethod
797
855
  @handle_db_timeout
798
856
  def size(
@@ -817,28 +875,8 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
817
875
  Raises:
818
876
  DBAPIError: If a database error occurs
819
877
  """
820
- logger.debug(f"Calculating size for {cls.__name__} with filters {kwargs}")
821
-
822
878
  with db_session as session:
823
- query = select(func.count()).select_from(cls)
824
-
825
- if actor:
826
- query = cls.apply_access_predicate(query, actor, access, access_type)
827
-
828
- # Apply filtering logic based on kwargs
829
- for key, value in kwargs.items():
830
- if value:
831
- column = getattr(cls, key, None)
832
- if not column:
833
- raise AttributeError(f"{cls.__name__} has no attribute '{key}'")
834
- if isinstance(value, (list, tuple, set)): # Check for iterables
835
- query = query.where(column.in_(value))
836
- else: # Single value for equality filtering
837
- query = query.where(column == value)
838
-
839
- # Handle soft deletes if the class has the 'is_deleted' attribute
840
- if hasattr(cls, "is_deleted"):
841
- query = query.where(cls.is_deleted == False)
879
+ query = cls._size_preprocess(db_session=session, actor=actor, access=access, access_type=access_type, **kwargs)
842
880
 
843
881
  try:
844
882
  count = session.execute(query).scalar()
@@ -847,6 +885,37 @@ class SqlalchemyBase(CommonSqlalchemyMetaMixins, Base):
847
885
  logger.exception(f"Failed to calculate size for {cls.__name__}")
848
886
  raise e
849
887
 
888
+ @classmethod
889
+ @handle_db_timeout
890
+ async def size_async(
891
+ cls,
892
+ *,
893
+ db_session: "AsyncSession",
894
+ actor: Optional["User"] = None,
895
+ access: Optional[List[Literal["read", "write", "admin"]]] = ["read"],
896
+ access_type: AccessType = AccessType.ORGANIZATION,
897
+ **kwargs,
898
+ ) -> int:
899
+ """
900
+ Get the count of rows that match the provided filters.
901
+ Args:
902
+ db_session: SQLAlchemy session
903
+ **kwargs: Filters to apply to the query (e.g., column_name=value)
904
+ Returns:
905
+ int: The count of rows that match the filters
906
+ Raises:
907
+ DBAPIError: If a database error occurs
908
+ """
909
+ async with db_session as session:
910
+ query = cls._size_preprocess(db_session=session, actor=actor, access=access, access_type=access_type, **kwargs)
911
+
912
+ try:
913
+ count = await session.execute(query).scalar()
914
+ return count if count else 0
915
+ except DBAPIError as e:
916
+ logger.exception(f"Failed to calculate size for {cls.__name__}")
917
+ raise e
918
+
850
919
  @classmethod
851
920
  def apply_access_predicate(
852
921
  cls,
@@ -83,7 +83,7 @@ async def list_agents(
83
83
  """
84
84
 
85
85
  # Retrieve the actor (user) details
86
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
86
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
87
87
 
88
88
  # Call list_agents directly without unnecessary dict handling
89
89
  return await server.agent_manager.list_agents_async(
@@ -163,7 +163,7 @@ async def import_agent_serialized(
163
163
  """
164
164
  Import a serialized agent file and recreate the agent in the system.
165
165
  """
166
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
166
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
167
167
 
168
168
  try:
169
169
  serialized_data = await file.read()
@@ -233,7 +233,7 @@ async def create_agent(
233
233
  Create a new agent with the specified configuration.
234
234
  """
235
235
  try:
236
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
236
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
237
237
  return await server.create_agent_async(agent, actor=actor)
238
238
  except Exception as e:
239
239
  traceback.print_exc()
@@ -248,7 +248,7 @@ async def modify_agent(
248
248
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
249
249
  ):
250
250
  """Update an existing agent"""
251
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
251
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
252
252
  return await server.update_agent_async(agent_id=agent_id, request=update_agent, actor=actor)
253
253
 
254
254
 
@@ -333,7 +333,7 @@ def detach_source(
333
333
 
334
334
 
335
335
  @router.get("/{agent_id}", response_model=AgentState, operation_id="retrieve_agent")
336
- def retrieve_agent(
336
+ async def retrieve_agent(
337
337
  agent_id: str,
338
338
  server: "SyncServer" = Depends(get_letta_server),
339
339
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
@@ -344,7 +344,7 @@ def retrieve_agent(
344
344
  actor = server.user_manager.get_user_or_default(user_id=actor_id)
345
345
 
346
346
  try:
347
- return server.agent_manager.get_agent_by_id(agent_id=agent_id, actor=actor)
347
+ return await server.agent_manager.get_agent_by_id_async(agent_id=agent_id, actor=actor)
348
348
  except NoResultFound as e:
349
349
  raise HTTPException(status_code=404, detail=str(e))
350
350
 
@@ -414,7 +414,7 @@ def retrieve_block(
414
414
 
415
415
 
416
416
  @router.get("/{agent_id}/core-memory/blocks", response_model=List[Block], operation_id="list_core_memory_blocks")
417
- def list_blocks(
417
+ async def list_blocks(
418
418
  agent_id: str,
419
419
  server: "SyncServer" = Depends(get_letta_server),
420
420
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
@@ -424,7 +424,7 @@ def list_blocks(
424
424
  """
425
425
  actor = server.user_manager.get_user_or_default(user_id=actor_id)
426
426
  try:
427
- agent = server.agent_manager.get_agent_by_id(agent_id, actor)
427
+ agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor)
428
428
  return agent.memory.blocks
429
429
  except NoResultFound as e:
430
430
  raise HTTPException(status_code=404, detail=str(e))
@@ -628,9 +628,9 @@ async def send_message(
628
628
  Process a user message and return the agent's response.
629
629
  This endpoint accepts a message from a user and processes it through the agent.
630
630
  """
631
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
631
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
632
632
  # TODO: This is redundant, remove soon
633
- agent = server.agent_manager.get_agent_by_id(agent_id, actor)
633
+ agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor)
634
634
  agent_eligible = not agent.enable_sleeptime and not agent.multi_agent_group and agent.agent_type != AgentType.sleeptime_agent
635
635
  experimental_header = request_obj.headers.get("X-EXPERIMENTAL") or "false"
636
636
  feature_enabled = settings.use_experimental or experimental_header.lower() == "true"
@@ -686,13 +686,13 @@ async def send_message_streaming(
686
686
  It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.
687
687
  """
688
688
  request_start_timestamp_ns = get_utc_timestamp_ns()
689
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
689
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
690
690
  # TODO: This is redundant, remove soon
691
- agent = server.agent_manager.get_agent_by_id(agent_id, actor)
691
+ agent = await server.agent_manager.get_agent_by_id_async(agent_id, actor)
692
692
  agent_eligible = not agent.enable_sleeptime and not agent.multi_agent_group and agent.agent_type != AgentType.sleeptime_agent
693
693
  experimental_header = request_obj.headers.get("X-EXPERIMENTAL") or "false"
694
694
  feature_enabled = settings.use_experimental or experimental_header.lower() == "true"
695
- model_compatible = agent.llm_config.model_endpoint_type == "anthropic"
695
+ model_compatible = agent.llm_config.model_endpoint_type in ["anthropic", "openai"]
696
696
 
697
697
  if agent_eligible and feature_enabled and model_compatible and request.stream_tokens:
698
698
  experimental_agent = LettaAgent(
@@ -705,7 +705,9 @@ async def send_message_streaming(
705
705
  )
706
706
 
707
707
  result = StreamingResponse(
708
- experimental_agent.step_stream(request.messages, max_steps=10, use_assistant_message=request.use_assistant_message),
708
+ experimental_agent.step_stream(
709
+ request.messages, max_steps=10, use_assistant_message=request.use_assistant_message, stream_tokens=request.stream_tokens
710
+ ),
709
711
  media_type="text/event-stream",
710
712
  )
711
713
  else:
@@ -784,7 +786,7 @@ async def send_message_async(
784
786
  Asynchronously process a user message and return a run object.
785
787
  The actual processing happens in the background, and the status can be checked using the run ID.
786
788
  """
787
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
789
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
788
790
 
789
791
  # Create a new job
790
792
  run = Run(
@@ -838,6 +840,6 @@ async def list_agent_groups(
838
840
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
839
841
  ):
840
842
  """Lists the groups for an agent"""
841
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
843
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
842
844
  print("in list agents with manager_type", manager_type)
843
845
  return server.agent_manager.list_groups(agent_id=agent_id, manager_type=manager_type, actor=actor)
@@ -26,7 +26,7 @@ async def list_blocks(
26
26
  server: SyncServer = Depends(get_letta_server),
27
27
  actor_id: Optional[str] = Header(None, alias="user_id"), # Extract user_id from header, default to None if not present
28
28
  ):
29
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
29
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
30
30
  return await server.block_manager.get_blocks_async(
31
31
  actor=actor,
32
32
  label=label,
@@ -135,7 +135,7 @@ async def send_group_message(
135
135
  Process a user message and return the group's response.
136
136
  This endpoint accepts a message from a user and processes it through through agents in the group based on the specified pattern
137
137
  """
138
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
138
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
139
139
  result = await server.send_group_message_to_agent(
140
140
  group_id=group_id,
141
141
  actor=actor,
@@ -174,7 +174,7 @@ async def send_group_message_streaming(
174
174
  This endpoint accepts a message from a user and processes it through agents in the group based on the specified pattern.
175
175
  It will stream the steps of the response always, and stream the tokens if 'stream_tokens' is set to True.
176
176
  """
177
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
177
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
178
178
  result = await server.send_group_message_to_agent(
179
179
  group_id=group_id,
180
180
  actor=actor,
@@ -52,7 +52,7 @@ async def create_messages_batch(
52
52
  detail=f"Server misconfiguration: LETTA_ENABLE_BATCH_JOB_POLLING is set to False.",
53
53
  )
54
54
 
55
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
55
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
56
56
  batch_job = BatchJob(
57
57
  user_id=actor.id,
58
58
  status=JobStatus.running,
@@ -100,7 +100,7 @@ async def retrieve_batch_run(
100
100
  """
101
101
  Get the status of a batch run.
102
102
  """
103
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
103
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
104
104
 
105
105
  try:
106
106
  job = await server.job_manager.get_job_by_id_async(job_id=batch_id, actor=actor)
@@ -118,7 +118,7 @@ async def list_batch_runs(
118
118
  List all batch runs.
119
119
  """
120
120
  # TODO: filter
121
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
121
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
122
122
 
123
123
  jobs = server.job_manager.list_jobs(actor=actor, statuses=[JobStatus.created, JobStatus.running], job_type=JobType.BATCH)
124
124
  return [BatchJob.from_job(job) for job in jobs]
@@ -150,7 +150,7 @@ async def list_batch_messages(
150
150
  - For subsequent pages, use the ID of the last message from the previous response as the cursor
151
151
  - Results will include messages before/after the cursor based on sort_descending
152
152
  """
153
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
153
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
154
154
 
155
155
  # First, verify the batch job exists and the user has access to it
156
156
  try:
@@ -177,7 +177,7 @@ async def cancel_batch_run(
177
177
  """
178
178
  Cancel a batch run.
179
179
  """
180
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
180
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
181
181
 
182
182
  try:
183
183
  job = await server.job_manager.get_job_by_id_async(job_id=batch_id, actor=actor)
@@ -115,7 +115,7 @@ async def list_run_messages(
115
115
  if order not in ["asc", "desc"]:
116
116
  raise HTTPException(status_code=400, detail="Order must be 'asc' or 'desc'")
117
117
 
118
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
118
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
119
119
 
120
120
  try:
121
121
  messages = server.job_manager.get_run_messages(
@@ -182,7 +182,7 @@ async def list_run_steps(
182
182
  if order not in ["asc", "desc"]:
183
183
  raise HTTPException(status_code=400, detail="Order must be 'asc' or 'desc'")
184
184
 
185
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
185
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
186
186
 
187
187
  try:
188
188
  steps = server.job_manager.get_job_steps(
@@ -87,7 +87,7 @@ async def list_tools(
87
87
  Get a list of all tools available to agents belonging to the org of the user
88
88
  """
89
89
  try:
90
- actor = server.user_manager.get_user_or_default(user_id=actor_id)
90
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=actor_id)
91
91
  if name is not None:
92
92
  tool = await server.tool_manager.get_tool_by_name_async(tool_name=name, actor=actor)
93
93
  return [tool] if tool else []
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/users", tags=["users", "admin"])
14
14
 
15
15
 
16
16
  @router.get("/", tags=["admin"], response_model=List[User], operation_id="list_users")
17
- def list_users(
17
+ async def list_users(
18
18
  after: Optional[str] = Query(None),
19
19
  limit: Optional[int] = Query(50),
20
20
  server: "SyncServer" = Depends(get_letta_server),
@@ -23,7 +23,7 @@ def list_users(
23
23
  Get a list of all users in the database
24
24
  """
25
25
  try:
26
- users = server.user_manager.list_users(after=after, limit=limit)
26
+ users = await server.user_manager.list_actors_async(after=after, limit=limit)
27
27
  except HTTPException:
28
28
  raise
29
29
  except Exception as e:
@@ -32,7 +32,7 @@ def list_users(
32
32
 
33
33
 
34
34
  @router.post("/", tags=["admin"], response_model=User, operation_id="create_user")
35
- def create_user(
35
+ async def create_user(
36
36
  request: UserCreate = Body(...),
37
37
  server: "SyncServer" = Depends(get_letta_server),
38
38
  ):
@@ -40,33 +40,33 @@ def create_user(
40
40
  Create a new user in the database
41
41
  """
42
42
  user = User(**request.model_dump())
43
- user = server.user_manager.create_user(user)
43
+ user = await server.user_manager.create_actor_async(user)
44
44
  return user
45
45
 
46
46
 
47
47
  @router.put("/", tags=["admin"], response_model=User, operation_id="update_user")
48
- def update_user(
48
+ async def update_user(
49
49
  user: UserUpdate = Body(...),
50
50
  server: "SyncServer" = Depends(get_letta_server),
51
51
  ):
52
52
  """
53
53
  Update a user in the database
54
54
  """
55
- user = server.user_manager.update_user(user)
55
+ user = await server.user_manager.update_actor_async(user)
56
56
  return user
57
57
 
58
58
 
59
59
  @router.delete("/", tags=["admin"], response_model=User, operation_id="delete_user")
60
- def delete_user(
60
+ async def delete_user(
61
61
  user_id: str = Query(..., description="The user_id key to be deleted."),
62
62
  server: "SyncServer" = Depends(get_letta_server),
63
63
  ):
64
64
  # TODO make a soft deletion, instead of a hard deletion
65
65
  try:
66
- user = server.user_manager.get_user_by_id(user_id=user_id)
66
+ user = await server.user_manager.get_actor_by_id_async(actor_id=user_id)
67
67
  if user is None:
68
68
  raise HTTPException(status_code=404, detail=f"User does not exist")
69
- server.user_manager.delete_user_by_id(user_id=user_id)
69
+ await server.user_manager.delete_actor_by_id_async(user_id=user_id)
70
70
  except HTTPException:
71
71
  raise
72
72
  except Exception as e:
@@ -36,7 +36,7 @@ async def create_voice_chat_completions(
36
36
  server: "SyncServer" = Depends(get_letta_server),
37
37
  user_id: Optional[str] = Header(None, alias="user_id"),
38
38
  ):
39
- actor = server.user_manager.get_user_or_default(user_id=user_id)
39
+ actor = await server.user_manager.get_actor_or_default_async(actor_id=user_id)
40
40
 
41
41
  # Create OpenAI async client
42
42
  client = openai.AsyncClient(
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from datetime import datetime, timezone
2
3
  from typing import Dict, List, Optional, Set, Tuple
3
4
 
@@ -905,12 +906,7 @@ class AgentManager:
905
906
 
906
907
  result = await session.execute(query)
907
908
  agents = result.scalars().all()
908
- pydantic_agents = []
909
- for agent in agents:
910
- pydantic_agent = await agent.to_pydantic_async(include_relationships=include_relationships)
911
- pydantic_agents.append(pydantic_agent)
912
-
913
- return pydantic_agents
909
+ return await asyncio.gather(*[agent.to_pydantic_async(include_relationships=include_relationships) for agent in agents])
914
910
 
915
911
  @enforce_types
916
912
  def list_agents_matching_tags(
@@ -1195,8 +1191,8 @@ class AgentManager:
1195
1191
 
1196
1192
  @enforce_types
1197
1193
  async def get_in_context_messages_async(self, agent_id: str, actor: PydanticUser) -> List[PydanticMessage]:
1198
- message_ids = self.get_agent_by_id(agent_id=agent_id, actor=actor).message_ids
1199
- return await self.message_manager.get_messages_by_ids_async(message_ids=message_ids, actor=actor)
1194
+ agent = await self.get_agent_by_id_async(agent_id=agent_id, actor=actor)
1195
+ return await self.message_manager.get_messages_by_ids_async(message_ids=agent.message_ids, actor=actor)
1200
1196
 
1201
1197
  @enforce_types
1202
1198
  def get_system_message(self, agent_id: str, actor: PydanticUser) -> PydanticMessage:
@@ -286,6 +286,21 @@ class MessageManager:
286
286
  with db_registry.session() as session:
287
287
  return MessageModel.size(db_session=session, actor=actor, role=role, agent_id=agent_id)
288
288
 
289
+ @enforce_types
290
+ async def size_async(
291
+ self,
292
+ actor: PydanticUser,
293
+ role: Optional[MessageRole] = None,
294
+ agent_id: Optional[str] = None,
295
+ ) -> int:
296
+ """Get the total count of messages with optional filters.
297
+ Args:
298
+ actor: The user requesting the count
299
+ role: The role of the message
300
+ """
301
+ async with db_registry.async_session() as session:
302
+ return await MessageModel.size_async(db_session=session, actor=actor, role=role, agent_id=agent_id)
303
+
289
304
  @enforce_types
290
305
  def list_user_messages_for_agent(
291
306
  self,
@@ -216,6 +216,20 @@ class PassageManager:
216
216
  with db_registry.session() as session:
217
217
  return AgentPassage.size(db_session=session, actor=actor, agent_id=agent_id)
218
218
 
219
+ @enforce_types
220
+ async def size_async(
221
+ self,
222
+ actor: PydanticUser,
223
+ agent_id: Optional[str] = None,
224
+ ) -> int:
225
+ """Get the total count of messages with optional filters.
226
+ Args:
227
+ actor: The user requesting the count
228
+ agent_id: The agent ID of the messages
229
+ """
230
+ async with db_registry.async_session() as session:
231
+ return await AgentPassage.size_async(db_session=session, actor=actor, agent_id=agent_id)
232
+
219
233
  def estimate_embeddings_size(
220
234
  self,
221
235
  actor: PydanticUser,
@@ -44,6 +44,14 @@ class UserManager:
44
44
  new_user.create(session)
45
45
  return new_user.to_pydantic()
46
46
 
47
+ @enforce_types
48
+ async def create_actor_async(self, pydantic_user: PydanticUser) -> PydanticUser:
49
+ """Create a new user if it doesn't already exist (async version)."""
50
+ async with db_registry.async_session() as session:
51
+ new_user = UserModel(**pydantic_user.model_dump(to_orm=True))
52
+ await new_user.create_async(session)
53
+ return new_user.to_pydantic()
54
+
47
55
  @enforce_types
48
56
  def update_user(self, user_update: UserUpdate) -> PydanticUser:
49
57
  """Update user details."""
@@ -60,6 +68,22 @@ class UserManager:
60
68
  existing_user.update(session)
61
69
  return existing_user.to_pydantic()
62
70
 
71
+ @enforce_types
72
+ async def update_actor_async(self, user_update: UserUpdate) -> PydanticUser:
73
+ """Update user details (async version)."""
74
+ async with db_registry.async_session() as session:
75
+ # Retrieve the existing user by ID
76
+ existing_user = await UserModel.read_async(db_session=session, identifier=user_update.id)
77
+
78
+ # Update only the fields that are provided in UserUpdate
79
+ update_data = user_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
80
+ for key, value in update_data.items():
81
+ setattr(existing_user, key, value)
82
+
83
+ # Commit the updated user
84
+ await existing_user.update_async(session)
85
+ return existing_user.to_pydantic()
86
+
63
87
  @enforce_types
64
88
  def delete_user_by_id(self, user_id: str):
65
89
  """Delete a user and their associated records (agents, sources, mappings)."""
@@ -70,6 +94,14 @@ class UserManager:
70
94
 
71
95
  session.commit()
72
96
 
97
+ @enforce_types
98
+ async def delete_actor_by_id_async(self, user_id: str):
99
+ """Delete a user and their associated records (agents, sources, mappings) asynchronously."""
100
+ async with db_registry.async_session() as session:
101
+ # Delete from user table
102
+ user = await UserModel.read_async(db_session=session, identifier=user_id)
103
+ await user.hard_delete_async(session)
104
+
73
105
  @enforce_types
74
106
  def get_user_by_id(self, user_id: str) -> PydanticUser:
75
107
  """Fetch a user by ID."""
@@ -77,6 +109,13 @@ class UserManager:
77
109
  user = UserModel.read(db_session=session, identifier=user_id)
78
110
  return user.to_pydantic()
79
111
 
112
+ @enforce_types
113
+ async def get_actor_by_id_async(self, actor_id: str) -> PydanticUser:
114
+ """Fetch a user by ID asynchronously."""
115
+ async with db_registry.async_session() as session:
116
+ user = await UserModel.read_async(db_session=session, identifier=actor_id)
117
+ return user.to_pydantic()
118
+
80
119
  @enforce_types
81
120
  def get_default_user(self) -> PydanticUser:
82
121
  """Fetch the default user. If it doesn't exist, create it."""
@@ -96,6 +135,26 @@ class UserManager:
96
135
  except NoResultFound:
97
136
  return self.get_default_user()
98
137
 
138
+ @enforce_types
139
+ async def get_default_actor_async(self) -> PydanticUser:
140
+ """Fetch the default user asynchronously. If it doesn't exist, create it."""
141
+ try:
142
+ return await self.get_actor_by_id_async(self.DEFAULT_USER_ID)
143
+ except NoResultFound:
144
+ # Fall back to synchronous version since create_default_user isn't async yet
145
+ return self.create_default_user(org_id=self.DEFAULT_ORG_ID)
146
+
147
+ @enforce_types
148
+ async def get_actor_or_default_async(self, actor_id: Optional[str] = None):
149
+ """Fetch the user or default user asynchronously."""
150
+ if not actor_id:
151
+ return await self.get_default_actor_async()
152
+
153
+ try:
154
+ return await self.get_actor_by_id_async(actor_id=actor_id)
155
+ except NoResultFound:
156
+ return await self.get_default_actor_async()
157
+
99
158
  @enforce_types
100
159
  def list_users(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticUser]:
101
160
  """List all users with optional pagination."""
@@ -106,3 +165,14 @@ class UserManager:
106
165
  limit=limit,
107
166
  )
108
167
  return [user.to_pydantic() for user in users]
168
+
169
+ @enforce_types
170
+ async def list_actors_async(self, after: Optional[str] = None, limit: Optional[int] = 50) -> List[PydanticUser]:
171
+ """List all users with optional pagination (async version)."""
172
+ async with db_registry.async_session() as session:
173
+ users = await UserModel.list_async(
174
+ db_session=session,
175
+ after=after,
176
+ limit=limit,
177
+ )
178
+ return [user.to_pydantic() for user in users]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: letta-nightly
3
- Version: 0.7.16.dev20250515205957
3
+ Version: 0.7.17.dev20250516104241
4
4
  Summary: Create LLM agents with long-term memory and custom tools
5
5
  License: Apache License
6
6
  Author: Letta Team
@@ -1,4 +1,4 @@
1
- letta/__init__.py,sha256=VzN4jgBctZzrLd4VJNhes1fZyqASwzLbNuO-LeaGUNo,916
1
+ letta/__init__.py,sha256=P8KgAQ7KWTfdnf40ctwC28i6fsLOCgFMMT1GAU26_gE,916
2
2
  letta/__main__.py,sha256=6Hs2PV7EYc5Tid4g4OtcLXhqVHiNYTGzSBdoOnW2HXA,29
3
3
  letta/agent.py,sha256=7f_vLO0b6pbCoXXzvgSIifViGRpg1MzeiesudknZyLk,72618
4
4
  letta/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -6,7 +6,7 @@ letta/agents/base_agent.py,sha256=ZmuSpYF8a5I0QXs8LDgS4jaA9k-6Pu2W-hl4E8A1ELo,82
6
6
  letta/agents/ephemeral_agent.py,sha256=el-SUF_16vv_7OouIR-6z0pAE9Yc0PLibygvfCKwqfo,2736
7
7
  letta/agents/exceptions.py,sha256=BQY4D4w32OYHM63CM19ko7dPwZiAzUs3NbKvzmCTcJg,318
8
8
  letta/agents/helpers.py,sha256=qJUmGgwVzNYTmV28sFoUu7MTuyqwzZ4qEpNXHVmSu6s,6055
9
- letta/agents/letta_agent.py,sha256=XlwvuZ6rMTky6R_oeuQvrocCfVNlfrPD8PfYB9IqYys,20567
9
+ letta/agents/letta_agent.py,sha256=f_DlUwOQ-Uogvb1XgSh63veBFkMotU7Fx1YTeMtyERo,21109
10
10
  letta/agents/letta_agent_batch.py,sha256=Z82Me5FV_jQ9PTopUvJ73F4NDFZeiDTgNPTQP5p9BIg,25292
11
11
  letta/agents/voice_agent.py,sha256=wCF2adlbDTEk_P3UrGPCHZy4IGvw75TUGDePW6N-sGA,21402
12
12
  letta/agents/voice_sleeptime_agent.py,sha256=gB44pOeIQJer_XqdxSNt7Txv0JaQAv9pzsiEFO9GYdY,7346
@@ -64,6 +64,7 @@ letta/interface.py,sha256=6GKasvJMASu-kcZch6Hffz1vnHuPA_ryI6cLH2bMArc,13023
64
64
  letta/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
65
  letta/interfaces/anthropic_streaming_interface.py,sha256=w2-adcgT18g_IZgOhjdxIALIHLD_lJrKGG_mx3VWn1c,21118
66
66
  letta/interfaces/openai_chat_completions_streaming_interface.py,sha256=LANdVBA8UNWscBvsFbWTT8cxNg5fHA_woWU2jkTf6TQ,4911
67
+ letta/interfaces/openai_streaming_interface.py,sha256=_N_vSs6sSwzzSSjCA8lKdinDlcvcWZDloQa4uFRstvQ,19978
67
68
  letta/interfaces/utils.py,sha256=c6jvO0dBYHh8DQnlN-B0qeNC64d3CSunhfqlFA4pJTY,278
68
69
  letta/jobs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
69
70
  letta/jobs/helpers.py,sha256=kO4aj954xsQ1RAmkjY6LQQ7JEIGuhaxB1e9pzrYKHAY,914
@@ -160,7 +161,7 @@ letta/orm/provider.py,sha256=KxIyUijtFapxXsgD86tWCRt1sG0TIETEyqlHEUWB7Fg,1312
160
161
  letta/orm/sandbox_config.py,sha256=DyOy_1_zCMlp13elCqPcuuA6OwUove6mrjhcpROTg50,4150
161
162
  letta/orm/source.py,sha256=rtehzez80rRrJigXeRBgTlfTZEUy6cVqDizWEN2tvuY,2224
162
163
  letta/orm/sources_agents.py,sha256=Ik_PokCBrXRd9wXWomeNeb8EtLUwjb9VMZ8LWXqpK5A,473
163
- letta/orm/sqlalchemy_base.py,sha256=LVHQsrO4JjWWVZ0IaCmgRhonPkkDWOmdHIBPPhb2Pc0,39239
164
+ letta/orm/sqlalchemy_base.py,sha256=AgDqsKfgsQSJjQGXm4VpgSG1ov4NKltlGkuQWuHiECM,41985
164
165
  letta/orm/sqlite_functions.py,sha256=JCScKiRlYCKxy9hChQ8wsk4GMKknZE24MunnG3fM1Gw,4255
165
166
  letta/orm/step.py,sha256=fjm7fLtYLCtFM6Mj6e2boP6P7dHSFG24Nem85VfVqHg,3216
166
167
  letta/orm/tool.py,sha256=ft3BDA7Pt-zsXLyPvS_Z_Ibis6H6vY20F7Li7p6nPu8,2652
@@ -266,25 +267,25 @@ letta/server/rest_api/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
266
267
  letta/server/rest_api/routers/openai/chat_completions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
267
268
  letta/server/rest_api/routers/openai/chat_completions/chat_completions.py,sha256=QBWab1fn2LXVDMtc6li3gOzmrNzDiUw5WUJsMeeMZII,5076
268
269
  letta/server/rest_api/routers/v1/__init__.py,sha256=_skmAcDOK9ovHKfywRaBgigo3IvPmnUSQSR2hGVCOhY,1664
269
- letta/server/rest_api/routers/v1/agents.py,sha256=CpHxBFmPiYwpXrhHwEeF0j3ZAIsn_05aUAz1_aCpkCg,35465
270
- letta/server/rest_api/routers/v1/blocks.py,sha256=XuHdwdWwDm7qdBIVv95MnUcekSEMJ1New_-CEqVZew8,4631
270
+ letta/server/rest_api/routers/v1/agents.py,sha256=EM0bpwJ2Dt3cGIux89N1v5PKxJbooFkLO0IbSmR7FOI,35716
271
+ letta/server/rest_api/routers/v1/blocks.py,sha256=Q0ZWhKiW1lOZmLLXy0IrJpTQ-Hp03psOWdfjJR2uoxI,4645
271
272
  letta/server/rest_api/routers/v1/embeddings.py,sha256=P-Dvt_HNKoTyjRwkScAMg1hlB3cNxMeAQwV7bSatsKI,957
272
- letta/server/rest_api/routers/v1/groups.py,sha256=JI9ShKewoE8lB58OP02NuAT7eUzPfqSG7y44a6tBh9s,10710
273
+ letta/server/rest_api/routers/v1/groups.py,sha256=DT2tc4wwiq_gzmxefltEIrFSoqOntzhvmgqQy23varA,10738
273
274
  letta/server/rest_api/routers/v1/health.py,sha256=MoOjkydhGcJXTiuJrKIB0etVXiRMdTa51S8RQ8-50DQ,399
274
275
  letta/server/rest_api/routers/v1/identities.py,sha256=fvp-0cwvb4iX1fUGPkL--9nq8YD3tIE47kYRxUgOlp4,7462
275
276
  letta/server/rest_api/routers/v1/jobs.py,sha256=4oeJfI2odNGubU_g7WSORJhn_usFsbRaD-qm86rve1E,2746
276
277
  letta/server/rest_api/routers/v1/llms.py,sha256=PZWNHq7QuKj71HzOIzNwLWgATqDQo54K26zzg9dLom0,1683
277
- letta/server/rest_api/routers/v1/messages.py,sha256=D5YAcU1_df8dPaarjZ_UboPI4yC7HMfr1rn5Nm2Xyp8,7780
278
+ letta/server/rest_api/routers/v1/messages.py,sha256=JvszNvPIe9mArExNInmJkcX33WInMbS5Vlds1eLqkjc,7850
278
279
  letta/server/rest_api/routers/v1/organizations.py,sha256=r7rj-cA3shgAgM0b2JCMqjYsDIFv3ruZjU7SYbPGGqg,2831
279
280
  letta/server/rest_api/routers/v1/providers.py,sha256=qp6XT20tcZac64XDGF2QUyLhselnShrRcTDQBHExEbQ,4322
280
- letta/server/rest_api/routers/v1/runs.py,sha256=9nuJRjBtRgZPq3CiCEUA_3S2xPHFP5DsJxIenH5OO34,8847
281
+ letta/server/rest_api/routers/v1/runs.py,sha256=rq-k5kYN0On7VBNSzoPJxZcBf13hZFaDx0IUJJ04_K8,8875
281
282
  letta/server/rest_api/routers/v1/sandbox_configs.py,sha256=9hqnnMwJ3wCwO-Bezu3Xl8i3TDSIuInw3gSeHaKUXfE,8526
282
283
  letta/server/rest_api/routers/v1/sources.py,sha256=cNDIckY1zqKUeB9xKg6jIoi-cePzyIew-OHMGeQvyqE,11222
283
284
  letta/server/rest_api/routers/v1/steps.py,sha256=ra7ttm7HDs3N52M6s80XdpwiSMTLyf776_SmEILWDvo,3276
284
285
  letta/server/rest_api/routers/v1/tags.py,sha256=coydgvL6-9cuG2Hy5Ea7QY3inhTHlsf69w0tcZenBus,880
285
- letta/server/rest_api/routers/v1/tools.py,sha256=5z1SjL3l8dFYm7Umfy9_01RT8fMGajnXtqxWy09TVME,19574
286
- letta/server/rest_api/routers/v1/users.py,sha256=G5DBHSkPfBgVHN2Wkm-rVYiLQAudwQczIq2Z3YLdbVo,2277
287
- letta/server/rest_api/routers/v1/voice.py,sha256=nSwjoW5Hi9EdScGyRWXpGVooAS0X2G-mOrpLUz0NqNs,1935
286
+ letta/server/rest_api/routers/v1/tools.py,sha256=IyGvDTRDY6UyadcmszqsprtwXKkAnA1C4fJyWCtRlfs,19588
287
+ letta/server/rest_api/routers/v1/users.py,sha256=a0J3Ad8kWHxi3vUJB5r9K2GmiplSABZXwhA83o8HbpI,2367
288
+ letta/server/rest_api/routers/v1/voice.py,sha256=NZa7ksEqXTWSqh7CqmbVMClO7wOmrqlRnSqFi6Qh-WM,1949
288
289
  letta/server/rest_api/static_files.py,sha256=NG8sN4Z5EJ8JVQdj19tkFa9iQ1kBPTab9f_CUxd_u4Q,3143
289
290
  letta/server/rest_api/utils.py,sha256=n5ZwtCtF3Oa4b9NFQ8l9f13v4eOI4mWdWNQqFp5d3A0,16516
290
291
  letta/server/server.py,sha256=ccbYv9c6xZNbLU3JQ7bgBthPvbl61fodubHDNRsnJxI,89795
@@ -301,7 +302,7 @@ letta/server/ws_api/interface.py,sha256=TWl9vkcMCnLsUtgsuENZ-ku2oMDA-OUTzLh_yNRo
301
302
  letta/server/ws_api/protocol.py,sha256=5mDgpfNZn_kNwHnpt5Dsuw8gdNH298sgxTGed3etzYg,1836
302
303
  letta/server/ws_api/server.py,sha256=cBSzf-V4zT1bL_0i54OTI3cMXhTIIxqjSRF8pYjk7fg,5835
303
304
  letta/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
304
- letta/services/agent_manager.py,sha256=3uWOSLwpwBBrNLC4aJhxQZahPccNDXROAp0iCoqyCJo,91707
305
+ letta/services/agent_manager.py,sha256=bcQRrWjECZCoIUD67LLh3XO7AY-9tRDfOY5d8OgmVNg,91590
305
306
  letta/services/block_manager.py,sha256=VxiDoshWbM27HQH9AqGQc4x1tsgjz8Csm-TBYiLy3IE,20779
306
307
  letta/services/group_manager.py,sha256=X5Z-0j9h95H5p3kKo8m5FZbl0HFN1slxFvcph7fTdvc,15833
307
308
  letta/services/helpers/agent_manager_helper.py,sha256=q7GfVgKI-e8k0BZS-V_PuUCjK-PYciZDoig_sYHi_Go,21334
@@ -314,9 +315,9 @@ letta/services/mcp/base_client.py,sha256=YoRb9eKKTGaLxaMVtuH5UcC74iXyWlcyYbC5xOe
314
315
  letta/services/mcp/sse_client.py,sha256=Vj0AgaadgMnpFQOWkSoPfeOI00ZvURMf3TIU7fv_DN8,1012
315
316
  letta/services/mcp/stdio_client.py,sha256=wdPzTqSRkibjt9pXhwi0Nul_z_cTAPim-OHjLc__yBE,925
316
317
  letta/services/mcp/types.py,sha256=nmcnQn2EpxXzXg5_pWPsHZobfxO6OucaUgz1bVvam7o,1411
317
- letta/services/message_manager.py,sha256=XVbmuQ9U1OJ2Fh3EE_9WKItWlCpYjAgyKWjAil1eC-M,20733
318
+ letta/services/message_manager.py,sha256=JENrzGpPIGX9STMnqktmbGX9C8ttI9MthQWlea3JphM,21274
318
319
  letta/services/organization_manager.py,sha256=Z87kY22pWm6yOmPJCsMUVQmu0kaxyK8WGKkyYaRM2sU,3760
319
- letta/services/passage_manager.py,sha256=dyaZdNZtuftwDrFyy8Sjv8UGmZAbHJTHgAJVdTzpua0,9924
320
+ letta/services/passage_manager.py,sha256=6-mVw6C1TWFJuaoE1CE8xaaiCPJLptfTb3k9XbhxGPQ,10419
320
321
  letta/services/per_agent_lock_manager.py,sha256=porM0cKKANQ1FvcGXOO_qM7ARk5Fgi1HVEAhXsAg9-4,546
321
322
  letta/services/provider_manager.py,sha256=l5gfCLMQ5imSoS1xT-6uFqNWEHBqXEriy6VRNkuJZ80,4758
322
323
  letta/services/sandbox_config_manager.py,sha256=tN6TYyOSNOZ3daX2QdbJcyGtUvczQc3-YZ-7dn5yLyE,13300
@@ -334,7 +335,7 @@ letta/services/tool_sandbox/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMp
334
335
  letta/services/tool_sandbox/base.py,sha256=pUnPFkEg9I5ktMuT4AOOxbTnTmZTGcTA2phLe1H1EdY,8306
335
336
  letta/services/tool_sandbox/e2b_sandbox.py,sha256=umsXfolzM_j67izswECDdVfnlcm03wLpMoZtS6SZ0sc,6147
336
337
  letta/services/tool_sandbox/local_sandbox.py,sha256=ksbraC-zcMWt3vS7kSi98uWI9L73I0h73rMayhuTWsw,10474
337
- letta/services/user_manager.py,sha256=lMOBMsFVrUgzlo6Y0b7O9geH3a0wpKuIJnRlGCqQ4oQ,4292
338
+ letta/services/user_manager.py,sha256=fDtPq8q2_LrIPHyn4zyx0orrCqKlpZRoqPU_IIaiBBc,7549
338
339
  letta/settings.py,sha256=h0d3tN3W3dEri5xlBthGwDUQBwaz_oZopy2vwRiILXA,8770
339
340
  letta/streaming_interface.py,sha256=c-T7zoMTXGXFwDWJJXrv7UypeMPXwPOmNHeuuh0b9zk,16398
340
341
  letta/streaming_utils.py,sha256=jLqFTVhUL76FeOuYk8TaRQHmPTf3HSRc2EoJwxJNK6U,11946
@@ -342,8 +343,8 @@ letta/system.py,sha256=mKxmvvekuP8mdgsebRINGBoFbUdJhxLJ260crPBNVyk,8386
342
343
  letta/tracing.py,sha256=j9uyBbx02erQZ307XmZmZSNyzQt-d7ZDB7vhFhjDlsU,8448
343
344
  letta/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
344
345
  letta/utils.py,sha256=W8J1FfhRADFqoyx3J8-Z1_aWyG433PBoEh_b5wdOZIg,32262
345
- letta_nightly-0.7.16.dev20250515205957.dist-info/LICENSE,sha256=mExtuZ_GYJgDEI38GWdiEYZizZS4KkVt2SF1g_GPNhI,10759
346
- letta_nightly-0.7.16.dev20250515205957.dist-info/METADATA,sha256=W3hd7GtM8raTDhyVmNpNxHkMy8Y80ot2YYC-mA_YOHA,22274
347
- letta_nightly-0.7.16.dev20250515205957.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
348
- letta_nightly-0.7.16.dev20250515205957.dist-info/entry_points.txt,sha256=2zdiyGNEZGV5oYBuS-y2nAAgjDgcC9yM_mHJBFSRt5U,40
349
- letta_nightly-0.7.16.dev20250515205957.dist-info/RECORD,,
346
+ letta_nightly-0.7.17.dev20250516104241.dist-info/LICENSE,sha256=mExtuZ_GYJgDEI38GWdiEYZizZS4KkVt2SF1g_GPNhI,10759
347
+ letta_nightly-0.7.17.dev20250516104241.dist-info/METADATA,sha256=kbyOGqE0QCbJRWC_lkvNEZevKOnK2jSeRXrwaIv9fvc,22274
348
+ letta_nightly-0.7.17.dev20250516104241.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
349
+ letta_nightly-0.7.17.dev20250516104241.dist-info/entry_points.txt,sha256=2zdiyGNEZGV5oYBuS-y2nAAgjDgcC9yM_mHJBFSRt5U,40
350
+ letta_nightly-0.7.17.dev20250516104241.dist-info/RECORD,,