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.
- letta/adapters/letta_llm_adapter.py +1 -0
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +7 -2
- letta/adapters/simple_llm_request_adapter.py +88 -0
- letta/adapters/simple_llm_stream_adapter.py +192 -0
- letta/agents/agent_loop.py +6 -0
- letta/agents/ephemeral_summary_agent.py +2 -1
- letta/agents/helpers.py +142 -6
- letta/agents/letta_agent.py +13 -33
- letta/agents/letta_agent_batch.py +2 -4
- letta/agents/letta_agent_v2.py +87 -77
- letta/agents/letta_agent_v3.py +899 -0
- letta/agents/voice_agent.py +2 -6
- letta/constants.py +8 -4
- letta/errors.py +40 -0
- letta/functions/function_sets/base.py +84 -4
- letta/functions/function_sets/multi_agent.py +0 -3
- letta/functions/schema_generator.py +113 -71
- letta/groups/dynamic_multi_agent.py +3 -2
- letta/groups/helpers.py +1 -2
- letta/groups/round_robin_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent_v2.py +1 -1
- letta/groups/sleeptime_multi_agent_v3.py +17 -17
- letta/groups/supervisor_multi_agent.py +84 -80
- letta/helpers/converters.py +3 -0
- letta/helpers/message_helper.py +4 -0
- letta/helpers/tool_rule_solver.py +92 -5
- letta/interfaces/anthropic_streaming_interface.py +409 -0
- letta/interfaces/gemini_streaming_interface.py +296 -0
- letta/interfaces/openai_streaming_interface.py +752 -1
- letta/llm_api/anthropic_client.py +126 -16
- letta/llm_api/bedrock_client.py +4 -2
- letta/llm_api/deepseek_client.py +4 -1
- letta/llm_api/google_vertex_client.py +123 -42
- letta/llm_api/groq_client.py +4 -1
- letta/llm_api/llm_api_tools.py +11 -4
- letta/llm_api/llm_client_base.py +6 -2
- letta/llm_api/openai.py +32 -2
- letta/llm_api/openai_client.py +423 -18
- letta/llm_api/xai_client.py +4 -1
- letta/main.py +9 -5
- letta/memory.py +1 -0
- letta/orm/__init__.py +1 -1
- letta/orm/agent.py +10 -0
- letta/orm/block.py +7 -16
- letta/orm/blocks_agents.py +8 -2
- letta/orm/files_agents.py +2 -0
- letta/orm/job.py +7 -5
- letta/orm/mcp_oauth.py +1 -0
- letta/orm/message.py +21 -6
- letta/orm/organization.py +2 -0
- letta/orm/provider.py +6 -2
- letta/orm/run.py +71 -0
- letta/orm/sandbox_config.py +7 -1
- letta/orm/sqlalchemy_base.py +0 -306
- letta/orm/step.py +6 -5
- letta/orm/step_metrics.py +5 -5
- letta/otel/tracing.py +28 -3
- letta/plugins/defaults.py +4 -4
- letta/prompts/system_prompts/__init__.py +2 -0
- letta/prompts/system_prompts/letta_v1.py +25 -0
- letta/schemas/agent.py +3 -2
- letta/schemas/agent_file.py +9 -3
- letta/schemas/block.py +23 -10
- letta/schemas/enums.py +21 -2
- letta/schemas/job.py +17 -4
- letta/schemas/letta_message_content.py +71 -2
- letta/schemas/letta_stop_reason.py +5 -5
- letta/schemas/llm_config.py +53 -3
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +504 -117
- letta/schemas/openai/responses_request.py +64 -0
- letta/schemas/providers/__init__.py +2 -0
- letta/schemas/providers/anthropic.py +16 -0
- letta/schemas/providers/ollama.py +115 -33
- letta/schemas/providers/openrouter.py +52 -0
- letta/schemas/providers/vllm.py +2 -1
- letta/schemas/run.py +48 -42
- letta/schemas/step.py +2 -2
- letta/schemas/step_metrics.py +1 -1
- letta/schemas/tool.py +15 -107
- letta/schemas/tool_rule.py +88 -5
- letta/serialize_schemas/marshmallow_agent.py +1 -0
- letta/server/db.py +86 -408
- letta/server/rest_api/app.py +61 -10
- letta/server/rest_api/dependencies.py +14 -0
- letta/server/rest_api/redis_stream_manager.py +19 -8
- letta/server/rest_api/routers/v1/agents.py +364 -292
- letta/server/rest_api/routers/v1/blocks.py +14 -20
- letta/server/rest_api/routers/v1/identities.py +45 -110
- letta/server/rest_api/routers/v1/internal_templates.py +21 -0
- letta/server/rest_api/routers/v1/jobs.py +23 -6
- letta/server/rest_api/routers/v1/messages.py +1 -1
- letta/server/rest_api/routers/v1/runs.py +126 -85
- letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
- letta/server/rest_api/routers/v1/tools.py +281 -594
- letta/server/rest_api/routers/v1/voice.py +1 -1
- letta/server/rest_api/streaming_response.py +29 -29
- letta/server/rest_api/utils.py +122 -64
- letta/server/server.py +160 -887
- letta/services/agent_manager.py +236 -919
- letta/services/agent_serialization_manager.py +16 -0
- letta/services/archive_manager.py +0 -100
- letta/services/block_manager.py +211 -168
- letta/services/file_manager.py +1 -1
- letta/services/files_agents_manager.py +24 -33
- letta/services/group_manager.py +0 -142
- letta/services/helpers/agent_manager_helper.py +7 -2
- letta/services/helpers/run_manager_helper.py +85 -0
- letta/services/job_manager.py +96 -411
- letta/services/lettuce/__init__.py +6 -0
- letta/services/lettuce/lettuce_client_base.py +86 -0
- letta/services/mcp_manager.py +38 -6
- letta/services/message_manager.py +165 -362
- letta/services/organization_manager.py +0 -36
- letta/services/passage_manager.py +0 -345
- letta/services/provider_manager.py +0 -80
- letta/services/run_manager.py +301 -0
- letta/services/sandbox_config_manager.py +0 -234
- letta/services/step_manager.py +62 -39
- letta/services/summarizer/summarizer.py +9 -7
- letta/services/telemetry_manager.py +0 -16
- letta/services/tool_executor/builtin_tool_executor.py +35 -0
- letta/services/tool_executor/core_tool_executor.py +397 -2
- letta/services/tool_executor/files_tool_executor.py +3 -3
- letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
- letta/services/tool_executor/tool_execution_manager.py +6 -8
- letta/services/tool_executor/tool_executor_base.py +3 -3
- letta/services/tool_manager.py +85 -339
- letta/services/tool_sandbox/base.py +24 -13
- letta/services/tool_sandbox/e2b_sandbox.py +16 -1
- letta/services/tool_schema_generator.py +123 -0
- letta/services/user_manager.py +0 -99
- letta/settings.py +20 -4
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
- letta/agents/temporal/activities/__init__.py +0 -4
- letta/agents/temporal/activities/example_activity.py +0 -7
- letta/agents/temporal/activities/prepare_messages.py +0 -10
- letta/agents/temporal/temporal_agent_workflow.py +0 -56
- letta/agents/temporal/types.py +0 -25
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
- {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
|
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
|
-
|
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
|
-
|
287
|
+
messages: List of messages to filter
|
297
288
|
actor: User performing the action
|
298
289
|
|
299
290
|
Returns:
|
300
|
-
|
291
|
+
Tuple of (new_messages, existing_messages)
|
301
292
|
"""
|
302
|
-
if
|
303
|
-
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
371
|
-
|
372
|
-
|
373
|
-
if
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
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
|
-
|
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
|
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.
|
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.
|
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
|
-
|
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
|
-
|
578
|
-
|
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
|
-
|
582
|
-
if
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
718
|
-
|
719
|
-
|
720
|
-
|
721
|
-
|
722
|
-
|
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.
|
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
|
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
|
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)
|
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
|
943
|
-
query = query.where(
|
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
|
-
|
1031
|
-
|
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
|
-
|
1034
|
-
|
1035
|
-
|
1036
|
-
|
1037
|
-
|
1038
|
-
|
1039
|
-
|
1040
|
-
|
1041
|
-
|
1042
|
-
|
1043
|
-
|
1044
|
-
|
1045
|
-
|
1046
|
-
|
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
|
-
|
1063
|
-
|
1064
|
-
|
1065
|
-
|
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
|
-
|
1085
|
-
|
1086
|
-
|
1087
|
-
|
1088
|
-
|
1089
|
-
|
1090
|
-
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
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.
|
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.
|
1006
|
+
messages = await self.list_messages(
|
1204
1007
|
agent_id=agent_id,
|
1205
1008
|
actor=actor,
|
1206
1009
|
query_text=query_text,
|