letta-nightly 0.11.7.dev20251006104136__py3-none-any.whl → 0.11.7.dev20251008104128__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.
Files changed (145) hide show
  1. letta/adapters/letta_llm_adapter.py +1 -0
  2. letta/adapters/letta_llm_request_adapter.py +0 -1
  3. letta/adapters/letta_llm_stream_adapter.py +7 -2
  4. letta/adapters/simple_llm_request_adapter.py +88 -0
  5. letta/adapters/simple_llm_stream_adapter.py +192 -0
  6. letta/agents/agent_loop.py +6 -0
  7. letta/agents/ephemeral_summary_agent.py +2 -1
  8. letta/agents/helpers.py +142 -6
  9. letta/agents/letta_agent.py +13 -33
  10. letta/agents/letta_agent_batch.py +2 -4
  11. letta/agents/letta_agent_v2.py +87 -77
  12. letta/agents/letta_agent_v3.py +899 -0
  13. letta/agents/voice_agent.py +2 -6
  14. letta/constants.py +8 -4
  15. letta/errors.py +40 -0
  16. letta/functions/function_sets/base.py +84 -4
  17. letta/functions/function_sets/multi_agent.py +0 -3
  18. letta/functions/schema_generator.py +113 -71
  19. letta/groups/dynamic_multi_agent.py +3 -2
  20. letta/groups/helpers.py +1 -2
  21. letta/groups/round_robin_multi_agent.py +3 -2
  22. letta/groups/sleeptime_multi_agent.py +3 -2
  23. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  24. letta/groups/sleeptime_multi_agent_v3.py +17 -17
  25. letta/groups/supervisor_multi_agent.py +84 -80
  26. letta/helpers/converters.py +3 -0
  27. letta/helpers/message_helper.py +4 -0
  28. letta/helpers/tool_rule_solver.py +92 -5
  29. letta/interfaces/anthropic_streaming_interface.py +409 -0
  30. letta/interfaces/gemini_streaming_interface.py +296 -0
  31. letta/interfaces/openai_streaming_interface.py +752 -1
  32. letta/llm_api/anthropic_client.py +126 -16
  33. letta/llm_api/bedrock_client.py +4 -2
  34. letta/llm_api/deepseek_client.py +4 -1
  35. letta/llm_api/google_vertex_client.py +123 -42
  36. letta/llm_api/groq_client.py +4 -1
  37. letta/llm_api/llm_api_tools.py +11 -4
  38. letta/llm_api/llm_client_base.py +6 -2
  39. letta/llm_api/openai.py +32 -2
  40. letta/llm_api/openai_client.py +423 -18
  41. letta/llm_api/xai_client.py +4 -1
  42. letta/main.py +9 -5
  43. letta/memory.py +1 -0
  44. letta/orm/__init__.py +1 -1
  45. letta/orm/agent.py +10 -0
  46. letta/orm/block.py +7 -16
  47. letta/orm/blocks_agents.py +8 -2
  48. letta/orm/files_agents.py +2 -0
  49. letta/orm/job.py +7 -5
  50. letta/orm/mcp_oauth.py +1 -0
  51. letta/orm/message.py +21 -6
  52. letta/orm/organization.py +2 -0
  53. letta/orm/provider.py +6 -2
  54. letta/orm/run.py +71 -0
  55. letta/orm/sandbox_config.py +7 -1
  56. letta/orm/sqlalchemy_base.py +0 -306
  57. letta/orm/step.py +6 -5
  58. letta/orm/step_metrics.py +5 -5
  59. letta/otel/tracing.py +28 -3
  60. letta/plugins/defaults.py +4 -4
  61. letta/prompts/system_prompts/__init__.py +2 -0
  62. letta/prompts/system_prompts/letta_v1.py +25 -0
  63. letta/schemas/agent.py +3 -2
  64. letta/schemas/agent_file.py +9 -3
  65. letta/schemas/block.py +23 -10
  66. letta/schemas/enums.py +21 -2
  67. letta/schemas/job.py +17 -4
  68. letta/schemas/letta_message_content.py +71 -2
  69. letta/schemas/letta_stop_reason.py +5 -5
  70. letta/schemas/llm_config.py +53 -3
  71. letta/schemas/memory.py +1 -1
  72. letta/schemas/message.py +504 -117
  73. letta/schemas/openai/responses_request.py +64 -0
  74. letta/schemas/providers/__init__.py +2 -0
  75. letta/schemas/providers/anthropic.py +16 -0
  76. letta/schemas/providers/ollama.py +115 -33
  77. letta/schemas/providers/openrouter.py +52 -0
  78. letta/schemas/providers/vllm.py +2 -1
  79. letta/schemas/run.py +48 -42
  80. letta/schemas/step.py +2 -2
  81. letta/schemas/step_metrics.py +1 -1
  82. letta/schemas/tool.py +15 -107
  83. letta/schemas/tool_rule.py +88 -5
  84. letta/serialize_schemas/marshmallow_agent.py +1 -0
  85. letta/server/db.py +86 -408
  86. letta/server/rest_api/app.py +61 -10
  87. letta/server/rest_api/dependencies.py +14 -0
  88. letta/server/rest_api/redis_stream_manager.py +19 -8
  89. letta/server/rest_api/routers/v1/agents.py +364 -292
  90. letta/server/rest_api/routers/v1/blocks.py +14 -20
  91. letta/server/rest_api/routers/v1/identities.py +45 -110
  92. letta/server/rest_api/routers/v1/internal_templates.py +21 -0
  93. letta/server/rest_api/routers/v1/jobs.py +23 -6
  94. letta/server/rest_api/routers/v1/messages.py +1 -1
  95. letta/server/rest_api/routers/v1/runs.py +126 -85
  96. letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
  97. letta/server/rest_api/routers/v1/tools.py +281 -594
  98. letta/server/rest_api/routers/v1/voice.py +1 -1
  99. letta/server/rest_api/streaming_response.py +29 -29
  100. letta/server/rest_api/utils.py +122 -64
  101. letta/server/server.py +160 -887
  102. letta/services/agent_manager.py +236 -919
  103. letta/services/agent_serialization_manager.py +16 -0
  104. letta/services/archive_manager.py +0 -100
  105. letta/services/block_manager.py +211 -168
  106. letta/services/file_manager.py +1 -1
  107. letta/services/files_agents_manager.py +24 -33
  108. letta/services/group_manager.py +0 -142
  109. letta/services/helpers/agent_manager_helper.py +7 -2
  110. letta/services/helpers/run_manager_helper.py +85 -0
  111. letta/services/job_manager.py +96 -411
  112. letta/services/lettuce/__init__.py +6 -0
  113. letta/services/lettuce/lettuce_client_base.py +86 -0
  114. letta/services/mcp_manager.py +38 -6
  115. letta/services/message_manager.py +165 -362
  116. letta/services/organization_manager.py +0 -36
  117. letta/services/passage_manager.py +0 -345
  118. letta/services/provider_manager.py +0 -80
  119. letta/services/run_manager.py +301 -0
  120. letta/services/sandbox_config_manager.py +0 -234
  121. letta/services/step_manager.py +62 -39
  122. letta/services/summarizer/summarizer.py +9 -7
  123. letta/services/telemetry_manager.py +0 -16
  124. letta/services/tool_executor/builtin_tool_executor.py +35 -0
  125. letta/services/tool_executor/core_tool_executor.py +397 -2
  126. letta/services/tool_executor/files_tool_executor.py +3 -3
  127. letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
  128. letta/services/tool_executor/tool_execution_manager.py +6 -8
  129. letta/services/tool_executor/tool_executor_base.py +3 -3
  130. letta/services/tool_manager.py +85 -339
  131. letta/services/tool_sandbox/base.py +24 -13
  132. letta/services/tool_sandbox/e2b_sandbox.py +16 -1
  133. letta/services/tool_schema_generator.py +123 -0
  134. letta/services/user_manager.py +0 -99
  135. letta/settings.py +20 -4
  136. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
  137. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
  138. letta/agents/temporal/activities/__init__.py +0 -4
  139. letta/agents/temporal/activities/example_activity.py +0 -7
  140. letta/agents/temporal/activities/prepare_messages.py +0 -10
  141. letta/agents/temporal/temporal_agent_workflow.py +0 -56
  142. letta/agents/temporal/types.py +0 -25
  143. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
  144. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
  145. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  import uuid
3
3
  from datetime import datetime
4
- from typing import List, Optional, Sequence, Tuple
4
+ from typing import List, Optional, Sequence, Set, Tuple
5
5
 
6
6
  from sqlalchemy import delete, exists, func, select, text
7
7
 
@@ -214,17 +214,6 @@ class MessageManager:
214
214
 
215
215
  return combined_messages
216
216
 
217
- @enforce_types
218
- @trace_method
219
- def get_message_by_id(self, message_id: str, actor: PydanticUser) -> Optional[PydanticMessage]:
220
- """Fetch a message by ID."""
221
- with db_registry.session() as session:
222
- try:
223
- message = MessageModel.read(db_session=session, identifier=message_id, actor=actor)
224
- return message.to_pydantic()
225
- except NoResultFound:
226
- return None
227
-
228
217
  @enforce_types
229
218
  @trace_method
230
219
  async def get_message_by_id_async(self, message_id: str, actor: PydanticUser) -> Optional[PydanticMessage]:
@@ -236,14 +225,6 @@ class MessageManager:
236
225
  except NoResultFound:
237
226
  return None
238
227
 
239
- @enforce_types
240
- @trace_method
241
- def get_messages_by_ids(self, message_ids: List[str], actor: PydanticUser) -> List[PydanticMessage]:
242
- """Fetch messages by ID and return them in the requested order."""
243
- with db_registry.session() as session:
244
- results = MessageModel.read_multiple(db_session=session, identifiers=message_ids, actor=actor)
245
- return self._get_messages_by_id_postprocess(results, message_ids)
246
-
247
228
  @enforce_types
248
229
  @trace_method
249
230
  async def get_messages_by_ids_async(self, message_ids: List[str], actor: PydanticUser) -> List[PydanticMessage]:
@@ -265,18 +246,6 @@ class MessageManager:
265
246
  result_dict = {msg.id: msg.to_pydantic() for msg in results}
266
247
  return list(filter(lambda x: x is not None, [result_dict.get(msg_id, None) for msg_id in message_ids]))
267
248
 
268
- @enforce_types
269
- @trace_method
270
- def create_message(self, pydantic_msg: PydanticMessage, actor: PydanticUser) -> PydanticMessage:
271
- """Create a new message."""
272
- with db_registry.session() as session:
273
- # Set the organization id of the Pydantic message
274
- msg_data = pydantic_msg.model_dump(to_orm=True)
275
- msg_data["organization_id"] = actor.organization_id
276
- msg = MessageModel(**msg_data)
277
- msg.create(session, actor=actor) # Persist to database
278
- return msg.to_pydantic()
279
-
280
249
  def _create_many_preprocess(self, pydantic_msgs: List[PydanticMessage], actor: PydanticUser) -> List[MessageModel]:
281
250
  # Create ORM model instances for all messages
282
251
  orm_messages = []
@@ -289,23 +258,48 @@ class MessageManager:
289
258
 
290
259
  @enforce_types
291
260
  @trace_method
292
- def create_many_messages(self, pydantic_msgs: List[PydanticMessage], actor: PydanticUser) -> List[PydanticMessage]:
261
+ async def check_existing_message_ids(self, message_ids: List[str], actor: PydanticUser) -> Set[str]:
262
+ """Check which message IDs already exist in the database.
263
+
264
+ Args:
265
+ message_ids: List of message IDs to check
266
+ actor: User performing the action
267
+
268
+ Returns:
269
+ Set of message IDs that already exist in the database
293
270
  """
294
- Create multiple messages in a single database transaction.
271
+ if not message_ids:
272
+ return set()
273
+
274
+ async with db_registry.async_session() as session:
275
+ query = select(MessageModel.id).where(MessageModel.id.in_(message_ids), MessageModel.organization_id == actor.organization_id)
276
+ result = await session.execute(query)
277
+ return set(result.scalars().all())
278
+
279
+ @enforce_types
280
+ @trace_method
281
+ async def filter_existing_messages(
282
+ self, messages: List[PydanticMessage], actor: PydanticUser
283
+ ) -> Tuple[List[PydanticMessage], List[PydanticMessage]]:
284
+ """Filter messages into new and existing based on their IDs.
285
+
295
286
  Args:
296
- pydantic_msgs: List of Pydantic message models to create
287
+ messages: List of messages to filter
297
288
  actor: User performing the action
298
289
 
299
290
  Returns:
300
- List of created Pydantic message models
291
+ Tuple of (new_messages, existing_messages)
301
292
  """
302
- if not pydantic_msgs:
303
- return []
293
+ message_ids = [msg.id for msg in messages if msg.id]
294
+ if not message_ids:
295
+ return messages, []
296
+
297
+ existing_ids = await self.check_existing_message_ids(message_ids, actor)
298
+
299
+ new_messages = [msg for msg in messages if msg.id not in existing_ids]
300
+ existing_messages = [msg for msg in messages if msg.id in existing_ids]
304
301
 
305
- orm_messages = self._create_many_preprocess(pydantic_msgs, actor)
306
- with db_registry.session() as session:
307
- created_messages = MessageModel.batch_create(orm_messages, session, actor=actor)
308
- return [msg.to_pydantic() for msg in created_messages]
302
+ return new_messages, existing_messages
309
303
 
310
304
  @enforce_types
311
305
  @trace_method
@@ -313,9 +307,11 @@ class MessageManager:
313
307
  self,
314
308
  pydantic_msgs: List[PydanticMessage],
315
309
  actor: PydanticUser,
310
+ run_id: Optional[str] = None,
316
311
  strict_mode: bool = False,
317
312
  project_id: Optional[str] = None,
318
313
  template_id: Optional[str] = None,
314
+ allow_partial: bool = False,
319
315
  ) -> List[PydanticMessage]:
320
316
  """
321
317
  Create multiple messages in a single database transaction asynchronously.
@@ -326,14 +322,33 @@ class MessageManager:
326
322
  strict_mode: If True, wait for embedding to complete; if False, run in background
327
323
  project_id: Optional project ID for the messages (for Turbopuffer indexing)
328
324
  template_id: Optional template ID for the messages (for Turbopuffer indexing)
325
+ allow_partial: If True, skip messages that already exist; if False, fail on duplicates
329
326
 
330
327
  Returns:
331
- List of created Pydantic message models
328
+ List of created Pydantic message models (and existing ones if allow_partial=True)
332
329
  """
333
330
  if not pydantic_msgs:
334
331
  return []
335
332
 
336
- for message in pydantic_msgs:
333
+ messages_to_create = pydantic_msgs
334
+ existing_messages = []
335
+
336
+ if allow_partial:
337
+ # filter out messages that already exist
338
+ new_messages, existing_messages = await self.filter_existing_messages(pydantic_msgs, actor)
339
+ messages_to_create = new_messages
340
+
341
+ if not messages_to_create:
342
+ # all messages already exist, fetch and return them
343
+ async with db_registry.async_session() as session:
344
+ existing_ids = [msg.id for msg in existing_messages if msg.id]
345
+ query = select(MessageModel).where(
346
+ MessageModel.id.in_(existing_ids), MessageModel.organization_id == actor.organization_id
347
+ )
348
+ result = await session.execute(query)
349
+ return [msg.to_pydantic() for msg in result.scalars()]
350
+
351
+ for message in messages_to_create:
337
352
  if isinstance(message.content, list):
338
353
  for content in message.content:
339
354
  if content.type == MessageContentType.image and content.source.type == ImageSourceType.base64:
@@ -358,30 +373,34 @@ class MessageManager:
358
373
  media_type=content.source.media_type,
359
374
  detail=content.source.detail,
360
375
  )
361
- orm_messages = self._create_many_preprocess(pydantic_msgs, actor)
376
+ orm_messages = self._create_many_preprocess(messages_to_create, actor)
362
377
  async with db_registry.async_session() as session:
363
378
  created_messages = await MessageModel.batch_create_async(orm_messages, session, actor=actor, no_commit=True, no_refresh=True)
364
379
  result = [msg.to_pydantic() for msg in created_messages]
365
380
  await session.commit()
366
381
 
367
- # embed messages in turbopuffer if enabled
368
- from letta.helpers.tpuf_client import should_use_tpuf_for_messages
382
+ from letta.helpers.tpuf_client import should_use_tpuf_for_messages
369
383
 
370
- if should_use_tpuf_for_messages() and result:
371
- # extract agent_id from the first message (all should have same agent_id)
372
- agent_id = result[0].agent_id
373
- if agent_id:
374
- if strict_mode:
375
- # wait for embedding to complete
376
- await self._embed_messages_background(result, actor, agent_id, project_id, template_id)
377
- else:
378
- # fire and forget - run embedding in background
379
- fire_and_forget(
380
- self._embed_messages_background(result, actor, agent_id, project_id, template_id),
381
- task_name=f"embed_messages_for_agent_{agent_id}",
382
- )
384
+ if should_use_tpuf_for_messages() and result:
385
+ agent_id = result[0].agent_id
386
+ if agent_id:
387
+ if strict_mode:
388
+ await self._embed_messages_background(result, actor, agent_id, project_id, template_id)
389
+ else:
390
+ fire_and_forget(
391
+ self._embed_messages_background(result, actor, agent_id, project_id, template_id),
392
+ task_name=f"embed_messages_for_agent_{agent_id}",
393
+ )
383
394
 
384
- return result
395
+ if allow_partial and existing_messages:
396
+ async with db_registry.async_session() as session:
397
+ existing_ids = [msg.id for msg in existing_messages if msg.id]
398
+ query = select(MessageModel).where(MessageModel.id.in_(existing_ids), MessageModel.organization_id == actor.organization_id)
399
+ existing_result = await session.execute(query)
400
+ existing_fetched = [msg.to_pydantic() for msg in existing_result.scalars()]
401
+ result.extend(existing_fetched)
402
+
403
+ return result
385
404
 
386
405
  async def _embed_messages_background(
387
406
  self,
@@ -441,13 +460,13 @@ class MessageManager:
441
460
 
442
461
  @enforce_types
443
462
  @trace_method
444
- def update_message_by_letta_message(
463
+ async def update_message_by_letta_message_async(
445
464
  self, message_id: str, letta_message_update: LettaMessageUpdateUnion, actor: PydanticUser
446
465
  ) -> PydanticMessage:
447
466
  """
448
467
  Updated the underlying messages table giving an update specified to the user-facing LettaMessage
449
468
  """
450
- message = self.get_message_by_id(message_id=message_id, actor=actor)
469
+ message = await self.get_message_by_id_async(message_id=message_id, actor=actor)
451
470
  if letta_message_update.message_type == "assistant_message":
452
471
  # modify the tool call for send_message
453
472
  # TODO: fix this if we add parallel tool calls
@@ -468,7 +487,7 @@ class MessageManager:
468
487
  else:
469
488
  raise ValueError(f"Unsupported message type for modification: {letta_message_update.message_type}")
470
489
 
471
- message = self.update_message_by_id(message_id=message_id, message_update=update_message, actor=actor)
490
+ message = await self.update_message_by_id_async(message_id=message_id, message_update=update_message, actor=actor)
472
491
 
473
492
  # convert back to LettaMessage
474
493
  for letta_msg in message.to_letta_messages(use_assistant_message=True):
@@ -478,63 +497,6 @@ class MessageManager:
478
497
  # raise error if message type got modified
479
498
  raise ValueError(f"Message type got modified: {letta_message_update.message_type}")
480
499
 
481
- @enforce_types
482
- @trace_method
483
- def update_message_by_letta_message(
484
- self, message_id: str, letta_message_update: LettaMessageUpdateUnion, actor: PydanticUser
485
- ) -> PydanticMessage:
486
- """
487
- Updated the underlying messages table giving an update specified to the user-facing LettaMessage
488
- """
489
- message = self.get_message_by_id(message_id=message_id, actor=actor)
490
- if letta_message_update.message_type == "assistant_message":
491
- # modify the tool call for send_message
492
- # TODO: fix this if we add parallel tool calls
493
- # TODO: note this only works if the AssistantMessage is generated by the standard send_message
494
- assert message.tool_calls[0].function.name == "send_message", (
495
- f"Expected the first tool call to be send_message, but got {message.tool_calls[0].function.name}"
496
- )
497
- original_args = json.loads(message.tool_calls[0].function.arguments)
498
- original_args["message"] = letta_message_update.content # override the assistant message
499
- update_tool_call = message.tool_calls[0].__deepcopy__()
500
- update_tool_call.function.arguments = json.dumps(original_args)
501
-
502
- update_message = MessageUpdate(tool_calls=[update_tool_call])
503
- elif letta_message_update.message_type == "reasoning_message":
504
- update_message = MessageUpdate(content=letta_message_update.reasoning)
505
- elif letta_message_update.message_type == "user_message" or letta_message_update.message_type == "system_message":
506
- update_message = MessageUpdate(content=letta_message_update.content)
507
- else:
508
- raise ValueError(f"Unsupported message type for modification: {letta_message_update.message_type}")
509
-
510
- message = self.update_message_by_id(message_id=message_id, message_update=update_message, actor=actor)
511
-
512
- # convert back to LettaMessage
513
- for letta_msg in message.to_letta_messages(use_assistant_message=True):
514
- if letta_msg.message_type == letta_message_update.message_type:
515
- return letta_msg
516
-
517
- # raise error if message type got modified
518
- raise ValueError(f"Message type got modified: {letta_message_update.message_type}")
519
-
520
- @enforce_types
521
- @trace_method
522
- def update_message_by_id(self, message_id: str, message_update: MessageUpdate, actor: PydanticUser) -> PydanticMessage:
523
- """
524
- Updates an existing record in the database with values from the provided record object.
525
- """
526
- with db_registry.session() as session:
527
- # Fetch existing message from database
528
- message = MessageModel.read(
529
- db_session=session,
530
- identifier=message_id,
531
- actor=actor,
532
- )
533
-
534
- message = self._update_message_by_id_impl(message_id, message_update, actor, message)
535
- message.update(db_session=session, actor=actor)
536
- return message.to_pydantic()
537
-
538
500
  @enforce_types
539
501
  @trace_method
540
502
  async def update_message_by_id_async(
@@ -571,26 +533,21 @@ class MessageManager:
571
533
  pydantic_message = message.to_pydantic()
572
534
  await session.commit()
573
535
 
574
- # update message in turbopuffer if enabled (delete and re-insert)
575
- from letta.helpers.tpuf_client import should_use_tpuf_for_messages
536
+ from letta.helpers.tpuf_client import should_use_tpuf_for_messages
576
537
 
577
- if should_use_tpuf_for_messages() and pydantic_message.agent_id:
578
- # extract text content from updated message
579
- text = self._extract_message_text(pydantic_message)
538
+ if should_use_tpuf_for_messages() and pydantic_message.agent_id:
539
+ text = self._extract_message_text(pydantic_message)
580
540
 
581
- # only update in turbopuffer if there's text content
582
- if text:
583
- if strict_mode:
584
- # wait for embedding update to complete
585
- await self._update_message_embedding_background(pydantic_message, text, actor, project_id, template_id)
586
- else:
587
- # fire and forget - run embedding update in background
588
- fire_and_forget(
589
- self._update_message_embedding_background(pydantic_message, text, actor, project_id, template_id),
590
- task_name=f"update_message_embedding_{message_id}",
591
- )
541
+ if text:
542
+ if strict_mode:
543
+ await self._update_message_embedding_background(pydantic_message, text, actor, project_id, template_id)
544
+ else:
545
+ fire_and_forget(
546
+ self._update_message_embedding_background(pydantic_message, text, actor, project_id, template_id),
547
+ task_name=f"update_message_embedding_{message_id}",
548
+ )
592
549
 
593
- return pydantic_message
550
+ return pydantic_message
594
551
 
595
552
  async def _update_message_embedding_background(
596
553
  self, message: PydanticMessage, text: str, actor: PydanticUser, project_id: Optional[str] = None, template_id: Optional[str] = None
@@ -654,26 +611,12 @@ class MessageManager:
654
611
  setattr(message, key, value)
655
612
  return message
656
613
 
657
- @enforce_types
658
- @trace_method
659
- def delete_message_by_id(self, message_id: str, actor: PydanticUser) -> bool:
660
- """Delete a message."""
661
- with db_registry.session() as session:
662
- try:
663
- msg = MessageModel.read(
664
- db_session=session,
665
- identifier=message_id,
666
- actor=actor,
667
- )
668
- msg.hard_delete(session, actor=actor)
669
- # Note: Turbopuffer deletion requires async, use delete_message_by_id_async for full deletion
670
- except NoResultFound:
671
- raise ValueError(f"Message with id {message_id} not found.")
672
-
673
614
  @enforce_types
674
615
  @trace_method
675
616
  async def delete_message_by_id_async(self, message_id: str, actor: PydanticUser, strict_mode: bool = False) -> bool:
676
617
  """Delete a message (async version with turbopuffer support)."""
618
+ # capture agent_id before deletion
619
+ agent_id = None
677
620
  async with db_registry.async_session() as session:
678
621
  try:
679
622
  msg = await MessageModel.read_async(
@@ -683,43 +626,22 @@ class MessageManager:
683
626
  )
684
627
  agent_id = msg.agent_id
685
628
  await msg.hard_delete_async(session, actor=actor)
686
-
687
- # delete from turbopuffer if enabled
688
- from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
689
-
690
- if should_use_tpuf_for_messages() and agent_id:
691
- try:
692
- tpuf_client = TurbopufferClient()
693
- await tpuf_client.delete_messages(
694
- agent_id=agent_id, organization_id=actor.organization_id, message_ids=[message_id]
695
- )
696
- logger.info(f"Successfully deleted message {message_id} from Turbopuffer")
697
- except Exception as e:
698
- logger.error(f"Failed to delete message from Turbopuffer: {e}")
699
- if strict_mode:
700
- raise # Re-raise the exception in strict mode
701
-
702
- return True
703
-
704
629
  except NoResultFound:
705
630
  raise ValueError(f"Message with id {message_id} not found.")
706
631
 
707
- @enforce_types
708
- @trace_method
709
- def size(
710
- self,
711
- actor: PydanticUser,
712
- role: Optional[MessageRole] = None,
713
- agent_id: Optional[str] = None,
714
- ) -> int:
715
- """Get the total count of messages with optional filters.
632
+ from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
716
633
 
717
- Args:
718
- actor: The user requesting the count
719
- role: The role of the message
720
- """
721
- with db_registry.session() as session:
722
- return MessageModel.size(db_session=session, actor=actor, role=role, agent_id=agent_id)
634
+ if should_use_tpuf_for_messages() and agent_id:
635
+ try:
636
+ tpuf_client = TurbopufferClient()
637
+ await tpuf_client.delete_messages(agent_id=agent_id, organization_id=actor.organization_id, message_ids=[message_id])
638
+ logger.info(f"Successfully deleted message {message_id} from Turbopuffer")
639
+ except Exception as e:
640
+ logger.error(f"Failed to delete message from Turbopuffer: {e}")
641
+ if strict_mode:
642
+ raise
643
+
644
+ return True
723
645
 
724
646
  @enforce_types
725
647
  @trace_method
@@ -737,29 +659,6 @@ class MessageManager:
737
659
  async with db_registry.async_session() as session:
738
660
  return await MessageModel.size_async(db_session=session, actor=actor, role=role, agent_id=agent_id)
739
661
 
740
- @enforce_types
741
- @trace_method
742
- def list_user_messages_for_agent(
743
- self,
744
- agent_id: str,
745
- actor: PydanticUser,
746
- after: Optional[str] = None,
747
- before: Optional[str] = None,
748
- query_text: Optional[str] = None,
749
- limit: Optional[int] = 50,
750
- ascending: bool = True,
751
- ) -> List[PydanticMessage]:
752
- return self.list_messages_for_agent(
753
- agent_id=agent_id,
754
- actor=actor,
755
- after=after,
756
- before=before,
757
- query_text=query_text,
758
- roles=[MessageRole.user],
759
- limit=limit,
760
- ascending=ascending,
761
- )
762
-
763
662
  @enforce_types
764
663
  @trace_method
765
664
  async def list_user_messages_for_agent_async(
@@ -771,8 +670,9 @@ class MessageManager:
771
670
  query_text: Optional[str] = None,
772
671
  limit: Optional[int] = 50,
773
672
  ascending: bool = True,
673
+ run_id: Optional[str] = None,
774
674
  ) -> List[PydanticMessage]:
775
- return await self.list_messages_for_agent_async(
675
+ return await self.list_messages(
776
676
  agent_id=agent_id,
777
677
  actor=actor,
778
678
  after=after,
@@ -781,117 +681,15 @@ class MessageManager:
781
681
  roles=[MessageRole.user],
782
682
  limit=limit,
783
683
  ascending=ascending,
684
+ run_id=run_id,
784
685
  )
785
686
 
786
687
  @enforce_types
787
688
  @trace_method
788
- def list_messages_for_agent(
689
+ async def list_messages(
789
690
  self,
790
- agent_id: str,
791
- actor: PydanticUser,
792
- after: Optional[str] = None,
793
- before: Optional[str] = None,
794
- query_text: Optional[str] = None,
795
- roles: Optional[Sequence[MessageRole]] = None,
796
- limit: Optional[int] = 50,
797
- ascending: bool = True,
798
- group_id: Optional[str] = None,
799
- ) -> List[PydanticMessage]:
800
- """
801
- Most performant query to list messages for an agent by directly querying the Message table.
802
-
803
- This function filters by the agent_id (leveraging the index on messages.agent_id)
804
- and applies pagination using sequence_id as the cursor.
805
- If query_text is provided, it will filter messages whose text content partially matches the query.
806
- If role is provided, it will filter messages by the specified role.
807
-
808
- Args:
809
- agent_id: The ID of the agent whose messages are queried.
810
- actor: The user performing the action (used for permission checks).
811
- after: A message ID; if provided, only messages *after* this message (by sequence_id) are returned.
812
- before: A message ID; if provided, only messages *before* this message (by sequence_id) are returned.
813
- query_text: Optional string to partially match the message text content.
814
- roles: Optional MessageRole to filter messages by role.
815
- limit: Maximum number of messages to return.
816
- ascending: If True, sort by sequence_id ascending; if False, sort descending.
817
- group_id: Optional group ID to filter messages by group_id.
818
-
819
- Returns:
820
- List[PydanticMessage]: A list of messages (converted via .to_pydantic()).
821
-
822
- Raises:
823
- NoResultFound: If the provided after/before message IDs do not exist.
824
- """
825
-
826
- with db_registry.session() as session:
827
- # Permission check: raise if the agent doesn't exist or actor is not allowed.
828
- AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
829
-
830
- # Build a query that directly filters the Message table by agent_id.
831
- query = session.query(MessageModel).filter(MessageModel.agent_id == agent_id)
832
-
833
- # If group_id is provided, filter messages by group_id.
834
- if group_id:
835
- query = query.filter(MessageModel.group_id == group_id)
836
-
837
- # If query_text is provided, filter messages using database-specific JSON search.
838
- if query_text:
839
- if settings.database_engine is DatabaseChoice.POSTGRES:
840
- # PostgreSQL: Use json_array_elements and ILIKE
841
- content_element = func.json_array_elements(MessageModel.content).alias("content_element")
842
- query = query.filter(
843
- exists(
844
- select(1)
845
- .select_from(content_element)
846
- .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
847
- .params(query_text=f"%{query_text}%")
848
- )
849
- )
850
- else:
851
- # SQLite: Use JSON_EXTRACT with individual array indices for case-insensitive search
852
- # Since SQLite doesn't support $[*] syntax, we'll use a different approach
853
- query = query.filter(text("JSON_EXTRACT(content, '$') LIKE :query_text")).params(query_text=f"%{query_text}%")
854
-
855
- # If role(s) are provided, filter messages by those roles.
856
- if roles:
857
- role_values = [r.value for r in roles]
858
- query = query.filter(MessageModel.role.in_(role_values))
859
-
860
- # Apply 'after' pagination if specified.
861
- if after:
862
- after_ref = session.query(MessageModel.sequence_id).filter(MessageModel.id == after).one_or_none()
863
- if not after_ref:
864
- raise NoResultFound(f"No message found with id '{after}' for agent '{agent_id}'.")
865
- # Filter out any messages with a sequence_id <= after_ref.sequence_id
866
- query = query.filter(MessageModel.sequence_id > after_ref.sequence_id)
867
-
868
- # Apply 'before' pagination if specified.
869
- if before:
870
- before_ref = session.query(MessageModel.sequence_id).filter(MessageModel.id == before).one_or_none()
871
- if not before_ref:
872
- raise NoResultFound(f"No message found with id '{before}' for agent '{agent_id}'.")
873
- # Filter out any messages with a sequence_id >= before_ref.sequence_id
874
- query = query.filter(MessageModel.sequence_id < before_ref.sequence_id)
875
-
876
- # Apply ordering based on the ascending flag.
877
- if ascending:
878
- query = query.order_by(MessageModel.sequence_id.asc())
879
- else:
880
- query = query.order_by(MessageModel.sequence_id.desc())
881
-
882
- # Limit the number of results.
883
- query = query.limit(limit)
884
-
885
- # Execute and convert each Message to its Pydantic representation.
886
- results = query.all()
887
- return [msg.to_pydantic() for msg in results]
888
-
889
- @enforce_types
890
- @trace_method
891
- async def list_messages_for_agent_async(
892
- self,
893
- agent_id: str,
894
691
  actor: PydanticUser,
692
+ agent_id: Optional[str] = None,
895
693
  after: Optional[str] = None,
896
694
  before: Optional[str] = None,
897
695
  query_text: Optional[str] = None,
@@ -900,9 +698,10 @@ class MessageManager:
900
698
  ascending: bool = True,
901
699
  group_id: Optional[str] = None,
902
700
  include_err: Optional[bool] = None,
701
+ run_id: Optional[str] = None,
903
702
  ) -> List[PydanticMessage]:
904
703
  """
905
- Most performant query to list messages for an agent by directly querying the Message table.
704
+ Most performant query to list messages by directly querying the Message table.
906
705
 
907
706
  This function filters by the agent_id (leveraging the index on messages.agent_id)
908
707
  and applies pagination using sequence_id as the cursor.
@@ -920,6 +719,7 @@ class MessageManager:
920
719
  ascending: If True, sort by sequence_id ascending; if False, sort descending.
921
720
  group_id: Optional group ID to filter messages by group_id.
922
721
  include_err: Optional boolean to include errors and error statuses. Used for debugging only.
722
+ run_id: Optional run ID to filter messages by run_id.
923
723
 
924
724
  Returns:
925
725
  List[PydanticMessage]: A list of messages (converted via .to_pydantic()).
@@ -930,17 +730,23 @@ class MessageManager:
930
730
 
931
731
  async with db_registry.async_session() as session:
932
732
  # Permission check: raise if the agent doesn't exist or actor is not allowed.
933
- await validate_agent_exists_async(session, agent_id, actor)
934
733
 
935
734
  # Build a query that directly filters the Message table by agent_id.
936
- query = select(MessageModel).where(MessageModel.agent_id == agent_id)
735
+ query = select(MessageModel)
736
+
737
+ if agent_id:
738
+ await validate_agent_exists_async(session, agent_id, actor)
739
+ query = query.where(MessageModel.agent_id == agent_id)
937
740
 
938
741
  # If group_id is provided, filter messages by group_id.
939
742
  if group_id:
940
743
  query = query.where(MessageModel.group_id == group_id)
941
744
 
942
- if not include_err:
943
- query = query.where((MessageModel.is_err == False) | (MessageModel.is_err.is_(None)))
745
+ if run_id:
746
+ query = query.where(MessageModel.run_id == run_id)
747
+
748
+ # if not include_err:
749
+ # query = query.where((MessageModel.is_err == False) | (MessageModel.is_err.is_(None)))
944
750
 
945
751
  # If query_text is provided, filter messages using database-specific JSON search.
946
752
  if query_text:
@@ -1009,6 +815,7 @@ class MessageManager:
1009
815
  while enforcing permission checks and avoiding any ORM‑level loads.
1010
816
  Optionally excludes specific message IDs from deletion.
1011
817
  """
818
+ rowcount = 0
1012
819
  async with db_registry.async_session() as session:
1013
820
  # 1) verify the agent exists and the actor has access
1014
821
  await validate_agent_exists_async(session, agent_id, actor)
@@ -1023,31 +830,28 @@ class MessageManager:
1023
830
  stmt = stmt.where(~MessageModel.id.in_(exclude_ids))
1024
831
 
1025
832
  result = await session.execute(stmt)
833
+ rowcount = result.rowcount
1026
834
 
1027
835
  # 4) commit once
1028
836
  await session.commit()
1029
837
 
1030
- # 5) delete from turbopuffer if enabled
1031
- from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
838
+ # 5) delete from turbopuffer if enabled (outside of DB session)
839
+ from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
1032
840
 
1033
- if should_use_tpuf_for_messages():
1034
- try:
1035
- tpuf_client = TurbopufferClient()
1036
- if exclude_ids:
1037
- # if we're excluding some IDs, we can't use delete_all
1038
- # would need to query all messages first then delete specific ones
1039
- # for now, log a warning
1040
- logger.warning(f"Turbopuffer deletion with exclude_ids not fully supported, using delete_all for agent {agent_id}")
1041
- # delete all messages for the agent from turbopuffer
1042
- await tpuf_client.delete_all_messages(agent_id, actor.organization_id)
1043
- logger.info(f"Successfully deleted all messages for agent {agent_id} from Turbopuffer")
1044
- except Exception as e:
1045
- logger.error(f"Failed to delete messages from Turbopuffer: {e}")
1046
- if strict_mode:
1047
- raise # Re-raise the exception in strict mode
1048
-
1049
- # 6) return the number of rows deleted
1050
- return result.rowcount
841
+ if should_use_tpuf_for_messages():
842
+ try:
843
+ tpuf_client = TurbopufferClient()
844
+ if exclude_ids:
845
+ logger.warning(f"Turbopuffer deletion with exclude_ids not fully supported, using delete_all for agent {agent_id}")
846
+ await tpuf_client.delete_all_messages(agent_id, actor.organization_id)
847
+ logger.info(f"Successfully deleted all messages for agent {agent_id} from Turbopuffer")
848
+ except Exception as e:
849
+ logger.error(f"Failed to delete messages from Turbopuffer: {e}")
850
+ if strict_mode:
851
+ raise
852
+
853
+ # 6) return the number of rows deleted
854
+ return rowcount
1051
855
 
1052
856
  @enforce_types
1053
857
  @trace_method
@@ -1059,11 +863,12 @@ class MessageManager:
1059
863
  if not message_ids:
1060
864
  return 0
1061
865
 
1062
- async with db_registry.async_session() as session:
1063
- # get agent_ids BEFORE deleting (for turbopuffer)
1064
- agent_ids = []
1065
- from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
866
+ agent_ids = []
867
+ rowcount = 0
868
+
869
+ from letta.helpers.tpuf_client import TurbopufferClient, should_use_tpuf_for_messages
1066
870
 
871
+ async with db_registry.async_session() as session:
1067
872
  if should_use_tpuf_for_messages():
1068
873
  agent_query = (
1069
874
  select(MessageModel.agent_id)
@@ -1077,25 +882,23 @@ class MessageManager:
1077
882
  # issue a CORE DELETE against the mapped class for specific message IDs
1078
883
  stmt = delete(MessageModel).where(MessageModel.id.in_(message_ids)).where(MessageModel.organization_id == actor.organization_id)
1079
884
  result = await session.execute(stmt)
885
+ rowcount = result.rowcount
1080
886
 
1081
887
  # commit once
1082
888
  await session.commit()
1083
889
 
1084
- # delete from turbopuffer if enabled
1085
- if should_use_tpuf_for_messages() and agent_ids:
1086
- try:
1087
- tpuf_client = TurbopufferClient()
1088
- # delete from each affected agent's namespace
1089
- for agent_id in agent_ids:
1090
- await tpuf_client.delete_messages(agent_id=agent_id, organization_id=actor.organization_id, message_ids=message_ids)
1091
- logger.info(f"Successfully deleted {len(message_ids)} messages from Turbopuffer")
1092
- except Exception as e:
1093
- logger.error(f"Failed to delete messages from Turbopuffer: {e}")
1094
- if strict_mode:
1095
- raise # Re-raise the exception in strict mode
1096
-
1097
- # return the number of rows deleted
1098
- return result.rowcount
890
+ if should_use_tpuf_for_messages() and agent_ids:
891
+ try:
892
+ tpuf_client = TurbopufferClient()
893
+ for agent_id in agent_ids:
894
+ await tpuf_client.delete_messages(agent_id=agent_id, organization_id=actor.organization_id, message_ids=message_ids)
895
+ logger.info(f"Successfully deleted {len(message_ids)} messages from Turbopuffer")
896
+ except Exception as e:
897
+ logger.error(f"Failed to delete messages from Turbopuffer: {e}")
898
+ if strict_mode:
899
+ raise
900
+
901
+ return rowcount
1099
902
 
1100
903
  @enforce_types
1101
904
  @trace_method
@@ -1180,7 +983,7 @@ class MessageManager:
1180
983
  except Exception as e:
1181
984
  logger.error(f"Failed to search messages with Turbopuffer, falling back to SQL: {e}")
1182
985
  # fall back to SQL search
1183
- messages = await self.list_messages_for_agent_async(
986
+ messages = await self.list_messages(
1184
987
  agent_id=agent_id,
1185
988
  actor=actor,
1186
989
  query_text=query_text,
@@ -1200,7 +1003,7 @@ class MessageManager:
1200
1003
  return message_tuples
1201
1004
  else:
1202
1005
  # use sql-based search
1203
- messages = await self.list_messages_for_agent_async(
1006
+ messages = await self.list_messages(
1204
1007
  agent_id=agent_id,
1205
1008
  actor=actor,
1206
1009
  query_text=query_text,