letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721070720__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 (99) hide show
  1. letta/__init__.py +1 -1
  2. letta/agent.py +27 -11
  3. letta/agents/helpers.py +1 -1
  4. letta/agents/letta_agent.py +518 -322
  5. letta/agents/letta_agent_batch.py +1 -2
  6. letta/agents/voice_agent.py +15 -17
  7. letta/client/client.py +3 -3
  8. letta/constants.py +5 -0
  9. letta/embeddings.py +0 -2
  10. letta/errors.py +8 -0
  11. letta/functions/function_sets/base.py +3 -3
  12. letta/functions/helpers.py +2 -3
  13. letta/groups/sleeptime_multi_agent.py +0 -1
  14. letta/helpers/composio_helpers.py +2 -2
  15. letta/helpers/converters.py +1 -1
  16. letta/helpers/pinecone_utils.py +8 -0
  17. letta/helpers/tool_rule_solver.py +13 -18
  18. letta/llm_api/aws_bedrock.py +16 -2
  19. letta/llm_api/cohere.py +1 -1
  20. letta/llm_api/openai_client.py +1 -1
  21. letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
  22. letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
  23. letta/local_llm/utils.py +1 -2
  24. letta/orm/agent.py +3 -3
  25. letta/orm/block.py +4 -4
  26. letta/orm/files_agents.py +0 -1
  27. letta/orm/identity.py +2 -0
  28. letta/orm/mcp_server.py +0 -2
  29. letta/orm/message.py +140 -14
  30. letta/orm/organization.py +5 -5
  31. letta/orm/passage.py +4 -4
  32. letta/orm/source.py +1 -1
  33. letta/orm/sqlalchemy_base.py +61 -39
  34. letta/orm/step.py +2 -0
  35. letta/otel/db_pool_monitoring.py +308 -0
  36. letta/otel/metric_registry.py +94 -1
  37. letta/otel/sqlalchemy_instrumentation.py +548 -0
  38. letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
  39. letta/otel/tracing.py +37 -1
  40. letta/schemas/agent.py +0 -3
  41. letta/schemas/agent_file.py +283 -0
  42. letta/schemas/block.py +0 -3
  43. letta/schemas/file.py +28 -26
  44. letta/schemas/letta_message.py +15 -4
  45. letta/schemas/memory.py +1 -1
  46. letta/schemas/message.py +31 -26
  47. letta/schemas/openai/chat_completion_response.py +0 -1
  48. letta/schemas/providers.py +20 -0
  49. letta/schemas/source.py +11 -13
  50. letta/schemas/step.py +12 -0
  51. letta/schemas/tool.py +0 -4
  52. letta/serialize_schemas/marshmallow_agent.py +14 -1
  53. letta/serialize_schemas/marshmallow_block.py +23 -1
  54. letta/serialize_schemas/marshmallow_message.py +1 -3
  55. letta/serialize_schemas/marshmallow_tool.py +23 -1
  56. letta/server/db.py +110 -6
  57. letta/server/rest_api/app.py +85 -73
  58. letta/server/rest_api/routers/v1/agents.py +68 -53
  59. letta/server/rest_api/routers/v1/blocks.py +2 -2
  60. letta/server/rest_api/routers/v1/jobs.py +3 -0
  61. letta/server/rest_api/routers/v1/organizations.py +2 -2
  62. letta/server/rest_api/routers/v1/sources.py +18 -2
  63. letta/server/rest_api/routers/v1/tools.py +11 -12
  64. letta/server/rest_api/routers/v1/users.py +1 -1
  65. letta/server/rest_api/streaming_response.py +13 -5
  66. letta/server/rest_api/utils.py +8 -25
  67. letta/server/server.py +11 -4
  68. letta/server/ws_api/server.py +2 -2
  69. letta/services/agent_file_manager.py +616 -0
  70. letta/services/agent_manager.py +133 -46
  71. letta/services/block_manager.py +38 -17
  72. letta/services/file_manager.py +106 -21
  73. letta/services/file_processor/file_processor.py +93 -0
  74. letta/services/files_agents_manager.py +28 -0
  75. letta/services/group_manager.py +4 -5
  76. letta/services/helpers/agent_manager_helper.py +57 -9
  77. letta/services/identity_manager.py +22 -0
  78. letta/services/job_manager.py +210 -91
  79. letta/services/llm_batch_manager.py +9 -6
  80. letta/services/mcp/stdio_client.py +1 -2
  81. letta/services/mcp_manager.py +0 -1
  82. letta/services/message_manager.py +49 -26
  83. letta/services/passage_manager.py +0 -1
  84. letta/services/provider_manager.py +1 -1
  85. letta/services/source_manager.py +114 -5
  86. letta/services/step_manager.py +36 -4
  87. letta/services/telemetry_manager.py +9 -2
  88. letta/services/tool_executor/builtin_tool_executor.py +5 -1
  89. letta/services/tool_executor/core_tool_executor.py +3 -3
  90. letta/services/tool_manager.py +95 -20
  91. letta/services/user_manager.py +4 -12
  92. letta/settings.py +23 -6
  93. letta/system.py +1 -1
  94. letta/utils.py +26 -2
  95. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
  96. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
  97. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
  98. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
  99. {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,4 @@
1
+ from datetime import datetime
1
2
  from functools import partial, reduce
2
3
  from operator import add
3
4
  from typing import List, Literal, Optional, Union
@@ -27,6 +28,7 @@ from letta.schemas.step import Step as PydanticStep
27
28
  from letta.schemas.usage import LettaUsageStatistics
28
29
  from letta.schemas.user import User as PydanticUser
29
30
  from letta.server.db import db_registry
31
+ from letta.settings import DatabaseChoice, settings
30
32
  from letta.utils import enforce_types
31
33
 
32
34
  logger = get_logger(__name__)
@@ -60,15 +62,29 @@ class JobManager:
60
62
  pydantic_job.user_id = actor.id
61
63
  job_data = pydantic_job.model_dump(to_orm=True)
62
64
  job = JobModel(**job_data)
63
- await job.create_async(session, actor=actor) # Save job in the database
64
- return job.to_pydantic()
65
+ job = await job.create_async(session, actor=actor, no_commit=True, no_refresh=True) # Save job in the database
66
+ result = job.to_pydantic()
67
+ await session.commit()
68
+ return result
65
69
 
66
70
  @enforce_types
67
71
  @trace_method
68
72
  def update_job_by_id(self, job_id: str, job_update: JobUpdate, actor: PydanticUser) -> PydanticJob:
69
73
  """Update a job by its ID with the given JobUpdate object."""
74
+ # First check if we need to dispatch a callback
75
+ needs_callback = False
76
+ callback_url = None
77
+ with db_registry.session() as session:
78
+ job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
79
+ not_completed_before = not bool(job.completed_at)
80
+
81
+ # Check if we'll need to dispatch callback
82
+ if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before and job.callback_url:
83
+ needs_callback = True
84
+ callback_url = job.callback_url
85
+
86
+ # Update the job first to get the final metadata
70
87
  with db_registry.session() as session:
71
- # Fetch the job by ID
72
88
  job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
73
89
  not_completed_before = not bool(job.completed_at)
74
90
 
@@ -84,24 +100,66 @@ class JobManager:
84
100
 
85
101
  if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before:
86
102
  job.completed_at = get_utc_time().replace(tzinfo=None)
87
- if job.callback_url:
88
- self._dispatch_callback(job)
89
103
 
90
- # Save the updated job to the database
91
- job.update(db_session=session, actor=actor)
92
-
93
- return job.to_pydantic()
104
+ # Save the updated job to the database first
105
+ job = job.update(db_session=session, actor=actor)
106
+
107
+ # Get the updated metadata for callback
108
+ final_metadata = job.metadata_
109
+ result = job.to_pydantic()
110
+
111
+ # Dispatch callback outside of database session if needed
112
+ if needs_callback:
113
+ callback_info = {
114
+ "job_id": job_id,
115
+ "callback_url": callback_url,
116
+ "status": job_update.status,
117
+ "completed_at": get_utc_time().replace(tzinfo=None),
118
+ "metadata": final_metadata,
119
+ }
120
+ callback_result = self._dispatch_callback_sync(callback_info)
121
+
122
+ # Update callback status in a separate transaction
123
+ with db_registry.session() as session:
124
+ job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
125
+ job.callback_sent_at = callback_result["callback_sent_at"]
126
+ job.callback_status_code = callback_result.get("callback_status_code")
127
+ job.callback_error = callback_result.get("callback_error")
128
+ job.update(db_session=session, actor=actor)
129
+ result = job.to_pydantic()
130
+
131
+ return result
94
132
 
95
133
  @enforce_types
96
134
  @trace_method
97
- async def update_job_by_id_async(self, job_id: str, job_update: JobUpdate, actor: PydanticUser) -> PydanticJob:
135
+ async def update_job_by_id_async(
136
+ self, job_id: str, job_update: JobUpdate, actor: PydanticUser, safe_update: bool = False
137
+ ) -> PydanticJob:
98
138
  """Update a job by its ID with the given JobUpdate object asynchronously."""
99
- callback_func = None
100
-
139
+ # First check if we need to dispatch a callback
140
+ needs_callback = False
141
+ callback_url = None
101
142
  async with db_registry.async_session() as session:
102
- # Fetch the job by ID
103
143
  job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
104
144
 
145
+ # Safely update job status with state transition guards: Created -> Pending -> Running --> <Terminal>
146
+ if safe_update:
147
+ current_status = JobStatus(job.status)
148
+ if not any(
149
+ (
150
+ job_update.status.is_terminal and not current_status.is_terminal,
151
+ current_status == JobStatus.created and job_update.status != JobStatus.created,
152
+ current_status == JobStatus.pending and job_update.status == JobStatus.running,
153
+ )
154
+ ):
155
+ logger.error(f"Invalid job status transition from {current_status} to {job_update.status} for job {job_id}")
156
+ raise ValueError(f"Invalid job status transition from {current_status} to {job_update.status}")
157
+
158
+ # Check if we'll need to dispatch callback
159
+ if job_update.status in {JobStatus.completed, JobStatus.failed} and job.callback_url:
160
+ needs_callback = True
161
+ callback_url = job.callback_url
162
+
105
163
  # Update job attributes with only the fields that were explicitly set
106
164
  update_data = job_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
107
165
 
@@ -116,25 +174,37 @@ class JobManager:
116
174
  if job_update.status in {JobStatus.completed, JobStatus.failed}:
117
175
  logger.info(f"Current job completed at: {job.completed_at}")
118
176
  job.completed_at = get_utc_time().replace(tzinfo=None)
119
- if job.callback_url:
120
- callback_func = self._dispatch_callback_async(
121
- callback_url=job.callback_url,
122
- payload={
123
- "job_id": job.id,
124
- "status": job.status,
125
- "completed_at": job.completed_at.isoformat() if job.completed_at else None,
126
- "metadata": job.metadata_,
127
- },
128
- actor=actor,
129
- )
130
177
 
131
- # Save the updated job to the database
132
- await job.update_async(db_session=session, actor=actor)
178
+ # Save the updated job to the database first
179
+ job = await job.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
133
180
 
134
- if callback_func:
135
- return await callback_func
181
+ # Get the updated metadata for callback
182
+ final_metadata = job.metadata_
183
+ result = job.to_pydantic()
184
+ await session.commit()
136
185
 
137
- return job.to_pydantic()
186
+ # Dispatch callback outside of database session if needed
187
+ if needs_callback:
188
+ callback_info = {
189
+ "job_id": job_id,
190
+ "callback_url": callback_url,
191
+ "status": job_update.status,
192
+ "completed_at": get_utc_time().replace(tzinfo=None),
193
+ "metadata": final_metadata,
194
+ }
195
+ callback_result = await self._dispatch_callback_async(callback_info)
196
+
197
+ # Update callback status in a separate transaction
198
+ async with db_registry.async_session() as session:
199
+ job = await self._verify_job_access_async(session=session, job_id=job_id, actor=actor, access=["write"])
200
+ job.callback_sent_at = callback_result["callback_sent_at"]
201
+ job.callback_status_code = callback_result.get("callback_status_code")
202
+ job.callback_error = callback_result.get("callback_error")
203
+ await job.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
204
+ result = job.to_pydantic()
205
+ await session.commit()
206
+
207
+ return result
138
208
 
139
209
  @enforce_types
140
210
  @trace_method
@@ -149,20 +219,6 @@ class JobManager:
149
219
  True if update was successful, False if update was skipped due to invalid transition
150
220
  """
151
221
  try:
152
- # Get current job state
153
- current_job = await self.get_job_by_id_async(job_id=job_id, actor=actor)
154
-
155
- current_status = current_job.status
156
- if not any(
157
- (
158
- new_status.is_terminal and not current_status.is_terminal,
159
- current_status == JobStatus.created and new_status != JobStatus.created,
160
- current_status == JobStatus.pending and new_status == JobStatus.running,
161
- )
162
- ):
163
- logger.warning(f"Invalid job status transition from {current_job.status} to {new_status} for job {job_id}")
164
- return False
165
-
166
222
  job_update_builder = partial(JobUpdate, status=new_status)
167
223
  if metadata:
168
224
  job_update_builder = partial(job_update_builder, metadata=metadata)
@@ -238,24 +294,90 @@ class JobManager:
238
294
  source_id: Optional[str] = None,
239
295
  ) -> List[PydanticJob]:
240
296
  """List all jobs with optional pagination and status filter."""
297
+ from sqlalchemy import and_, or_, select
298
+
241
299
  async with db_registry.async_session() as session:
242
- filter_kwargs = {"user_id": actor.id, "job_type": job_type}
300
+ # build base query
301
+ query = select(JobModel).where(JobModel.user_id == actor.id).where(JobModel.job_type == job_type)
243
302
 
244
- # Add status filter if provided
303
+ # add status filter if provided
245
304
  if statuses:
246
- filter_kwargs["status"] = statuses
305
+ query = query.where(JobModel.status.in_(statuses))
247
306
 
307
+ # add source_id filter if provided
248
308
  if source_id:
249
- filter_kwargs["metadata_.source_id"] = source_id
309
+ column = getattr(JobModel, "metadata_")
310
+ column = column.op("->>")("source_id")
311
+ query = query.where(column == source_id)
312
+
313
+ # handle cursor-based pagination
314
+ if before or after:
315
+ # get cursor objects
316
+ before_obj = None
317
+ after_obj = None
318
+
319
+ if before:
320
+ before_obj = await session.get(JobModel, before)
321
+ if not before_obj:
322
+ raise ValueError(f"Job with id {before} not found")
323
+
324
+ if after:
325
+ after_obj = await session.get(JobModel, after)
326
+ if not after_obj:
327
+ raise ValueError(f"Job with id {after} not found")
328
+
329
+ # validate cursors
330
+ if before_obj and after_obj:
331
+ if before_obj.created_at < after_obj.created_at:
332
+ raise ValueError("'before' reference must be later than 'after' reference")
333
+ elif before_obj.created_at == after_obj.created_at and before_obj.id < after_obj.id:
334
+ raise ValueError("'before' reference must be later than 'after' reference")
335
+
336
+ # build cursor conditions
337
+ conditions = []
338
+ if before_obj:
339
+ # records before this cursor (older)
340
+
341
+ before_timestamp = before_obj.created_at
342
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
343
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_timestamp, datetime):
344
+ before_timestamp = before_timestamp.strftime("%Y-%m-%d %H:%M:%S")
345
+
346
+ conditions.append(
347
+ or_(
348
+ JobModel.created_at < before_timestamp,
349
+ and_(JobModel.created_at == before_timestamp, JobModel.id < before_obj.id),
350
+ )
351
+ )
352
+
353
+ if after_obj:
354
+ # records after this cursor (newer)
355
+ after_timestamp = after_obj.created_at
356
+ # SQLite does not support as granular timestamping, so we need to round the timestamp
357
+ if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_timestamp, datetime):
358
+ after_timestamp = after_timestamp.strftime("%Y-%m-%d %H:%M:%S")
359
+
360
+ conditions.append(
361
+ or_(JobModel.created_at > after_timestamp, and_(JobModel.created_at == after_timestamp, JobModel.id > after_obj.id))
362
+ )
363
+
364
+ if conditions:
365
+ query = query.where(and_(*conditions))
366
+
367
+ # apply ordering
368
+ if ascending:
369
+ query = query.order_by(JobModel.created_at.asc(), JobModel.id.asc())
370
+ else:
371
+ query = query.order_by(JobModel.created_at.desc(), JobModel.id.desc())
372
+
373
+ # apply limit
374
+ if limit:
375
+ query = query.limit(limit)
376
+
377
+ # execute query
378
+ result = await session.execute(query)
379
+ jobs = result.scalars().all()
250
380
 
251
- jobs = await JobModel.list_async(
252
- db_session=session,
253
- before=before,
254
- after=after,
255
- limit=limit,
256
- ascending=ascending,
257
- **filter_kwargs,
258
- )
259
381
  return [job.to_pydantic() for job in jobs]
260
382
 
261
383
  @enforce_types
@@ -617,7 +739,7 @@ class JobManager:
617
739
  session: Session,
618
740
  job_id: str,
619
741
  actor: PydanticUser,
620
- access: List[Literal["read", "write", "delete"]] = ["read"],
742
+ access: List[Literal["read", "write", "admin"]] = ["read"],
621
743
  ) -> JobModel:
622
744
  """
623
745
  Verify that a job exists and the user has the required access.
@@ -685,61 +807,58 @@ class JobManager:
685
807
  return request_config
686
808
 
687
809
  @trace_method
688
- def _dispatch_callback(self, job: JobModel) -> None:
810
+ def _dispatch_callback_sync(self, callback_info: dict) -> dict:
689
811
  """
690
- POST a standard JSON payload to job.callback_url
691
- and record timestamp + HTTP status.
812
+ POST a standard JSON payload to callback_url and return callback status.
692
813
  """
693
-
694
814
  payload = {
695
- "job_id": job.id,
696
- "status": job.status,
697
- "completed_at": job.completed_at.isoformat() if job.completed_at else None,
698
- "metadata": job.metadata_,
815
+ "job_id": callback_info["job_id"],
816
+ "status": callback_info["status"],
817
+ "completed_at": callback_info["completed_at"].isoformat() if callback_info["completed_at"] else None,
818
+ "metadata": callback_info["metadata"],
699
819
  }
820
+
821
+ callback_sent_at = get_utc_time().replace(tzinfo=None)
822
+ result = {"callback_sent_at": callback_sent_at}
823
+
700
824
  try:
701
825
  log_event("POST callback dispatched", payload)
702
- resp = post(job.callback_url, json=payload, timeout=5.0)
826
+ resp = post(callback_info["callback_url"], json=payload, timeout=5.0)
703
827
  log_event("POST callback finished")
704
- job.callback_sent_at = get_utc_time().replace(tzinfo=None)
705
- job.callback_status_code = resp.status_code
706
-
828
+ result["callback_status_code"] = resp.status_code
707
829
  except Exception as e:
708
- error_message = f"Failed to dispatch callback for job {job.id} to {job.callback_url}: {e!s}"
830
+ error_message = f"Failed to dispatch callback for job {callback_info['job_id']} to {callback_info['callback_url']}: {e!s}"
709
831
  logger.error(error_message)
710
- # Record the failed attempt
711
- job.callback_sent_at = get_utc_time().replace(tzinfo=None)
712
- job.callback_error = error_message
832
+ result["callback_error"] = error_message
713
833
  # Continue silently - callback failures should not affect job completion
714
834
 
835
+ return result
836
+
715
837
  @trace_method
716
- async def _dispatch_callback_async(self, callback_url: str, payload: dict, actor: PydanticUser) -> PydanticJob:
838
+ async def _dispatch_callback_async(self, callback_info: dict) -> dict:
717
839
  """
718
- POST a standard JSON payload to job.callback_url and record timestamp + HTTP status asynchronously.
840
+ POST a standard JSON payload to callback_url and return callback status asynchronously.
719
841
  """
720
- job_id = payload["job_id"]
721
- callback_sent_at, callback_status_code, callback_error = None, None, None
842
+ payload = {
843
+ "job_id": callback_info["job_id"],
844
+ "status": callback_info["status"],
845
+ "completed_at": callback_info["completed_at"].isoformat() if callback_info["completed_at"] else None,
846
+ "metadata": callback_info["metadata"],
847
+ }
848
+
849
+ callback_sent_at = get_utc_time().replace(tzinfo=None)
850
+ result = {"callback_sent_at": callback_sent_at}
722
851
 
723
852
  try:
724
853
  async with AsyncClient() as client:
725
854
  log_event("POST callback dispatched", payload)
726
- resp = await client.post(callback_url, json=payload, timeout=5.0)
855
+ resp = await client.post(callback_info["callback_url"], json=payload, timeout=5.0)
727
856
  log_event("POST callback finished")
728
- # Ensure timestamp is timezone-naive for DB compatibility
729
- callback_sent_at = get_utc_time().replace(tzinfo=None)
730
- callback_status_code = resp.status_code
857
+ result["callback_status_code"] = resp.status_code
731
858
  except Exception as e:
732
- error_message = f"Failed to dispatch callback for job {job_id} to {callback_url}: {e!s}"
859
+ error_message = f"Failed to dispatch callback for job {callback_info['job_id']} to {callback_info['callback_url']}: {e!s}"
733
860
  logger.error(error_message)
734
- # Record the failed attempt
735
- callback_sent_at = get_utc_time().replace(tzinfo=None)
736
- callback_error = error_message
861
+ result["callback_error"] = error_message
737
862
  # Continue silently - callback failures should not affect job completion
738
863
 
739
- async with db_registry.async_session() as session:
740
- job = await JobModel.read_async(db_session=session, identifier=job_id, actor=actor, access_type=AccessType.USER)
741
- job.callback_sent_at = callback_sent_at
742
- job.callback_status_code = callback_status_code
743
- job.callback_error = callback_error
744
- await job.update_async(db_session=session, actor=actor)
745
- return job.to_pydantic()
864
+ return result
@@ -45,8 +45,10 @@ class LLMBatchManager:
45
45
  organization_id=actor.organization_id,
46
46
  letta_batch_job_id=letta_batch_job_id,
47
47
  )
48
- await batch.create_async(session, actor=actor)
49
- return batch.to_pydantic()
48
+ await batch.create_async(session, actor=actor, no_commit=True, no_refresh=True)
49
+ pydantic_batch = batch.to_pydantic()
50
+ await session.commit()
51
+ return pydantic_batch
50
52
 
51
53
  @enforce_types
52
54
  @trace_method
@@ -282,10 +284,11 @@ class LLMBatchManager:
282
284
  )
283
285
  orm_items.append(orm_item)
284
286
 
285
- created_items = await LLMBatchItem.batch_create_async(orm_items, session, actor=actor)
287
+ created_items = await LLMBatchItem.batch_create_async(orm_items, session, actor=actor, no_commit=True, no_refresh=True)
286
288
 
287
- # Convert back to Pydantic models
288
- return [item.to_pydantic() for item in created_items]
289
+ pydantic_items = [item.to_pydantic() for item in created_items]
290
+ await session.commit()
291
+ return pydantic_items
289
292
 
290
293
  @enforce_types
291
294
  @trace_method
@@ -403,7 +406,7 @@ class LLMBatchManager:
403
406
  missing = requested - found
404
407
  if missing:
405
408
  raise ValueError(
406
- f"Cannot bulk-update batch items: no records for the following " f"(llm_batch_id, agent_id) pairs: {missing}"
409
+ f"Cannot bulk-update batch items: no records for the following (llm_batch_id, agent_id) pairs: {missing}"
407
410
  )
408
411
 
409
412
  # Build mappings, skipping any missing when strict=False
@@ -11,11 +11,10 @@ logger = get_logger(__name__)
11
11
  # TODO: Get rid of Async prefix on this class name once we deprecate old sync code
12
12
  class AsyncStdioMCPClient(AsyncBaseMCPClient):
13
13
  async def _initialize_connection(self, server_config: StdioServerConfig) -> None:
14
-
15
14
  args = [arg.split() for arg in server_config.args]
16
15
  # flatten
17
16
  args = [arg for sublist in args for arg in sublist]
18
- server_params = StdioServerParameters(command=server_config.command, args=args)
17
+ server_params = StdioServerParameters(command=server_config.command, args=args, env=server_config.env)
19
18
  stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
20
19
  self.stdio, self.write = stdio_transport
21
20
  self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
@@ -274,7 +274,6 @@ class MCPManager:
274
274
  mcp_config_path = os.path.join(constants.LETTA_DIR, constants.MCP_CONFIG_NAME)
275
275
  if os.path.exists(mcp_config_path):
276
276
  with open(mcp_config_path, "r") as f:
277
-
278
277
  try:
279
278
  mcp_config = json.load(f)
280
279
  except Exception as e:
@@ -17,6 +17,8 @@ from letta.schemas.message import MessageUpdate
17
17
  from letta.schemas.user import User as PydanticUser
18
18
  from letta.server.db import db_registry
19
19
  from letta.services.file_manager import FileManager
20
+ from letta.services.helpers.agent_manager_helper import validate_agent_exists_async
21
+ from letta.settings import DatabaseChoice, settings
20
22
  from letta.utils import enforce_types
21
23
 
22
24
  logger = get_logger(__name__)
@@ -86,8 +88,8 @@ class MessageManager:
86
88
  """Create a new message."""
87
89
  with db_registry.session() as session:
88
90
  # Set the organization id of the Pydantic message
89
- pydantic_msg.organization_id = actor.organization_id
90
91
  msg_data = pydantic_msg.model_dump(to_orm=True)
92
+ msg_data["organization_id"] = actor.organization_id
91
93
  msg = MessageModel(**msg_data)
92
94
  msg.create(session, actor=actor) # Persist to database
93
95
  return msg.to_pydantic()
@@ -97,8 +99,8 @@ class MessageManager:
97
99
  orm_messages = []
98
100
  for pydantic_msg in pydantic_msgs:
99
101
  # Set the organization id of the Pydantic message
100
- pydantic_msg.organization_id = actor.organization_id
101
102
  msg_data = pydantic_msg.model_dump(to_orm=True)
103
+ msg_data["organization_id"] = actor.organization_id
102
104
  orm_messages.append(MessageModel(**msg_data))
103
105
  return orm_messages
104
106
 
@@ -165,8 +167,10 @@ class MessageManager:
165
167
  )
166
168
  orm_messages = self._create_many_preprocess(pydantic_msgs, actor)
167
169
  async with db_registry.async_session() as session:
168
- created_messages = await MessageModel.batch_create_async(orm_messages, session, actor=actor)
169
- return [msg.to_pydantic() for msg in created_messages]
170
+ created_messages = await MessageModel.batch_create_async(orm_messages, session, actor=actor, no_commit=True, no_refresh=True)
171
+ result = [msg.to_pydantic() for msg in created_messages]
172
+ await session.commit()
173
+ return result
170
174
 
171
175
  @enforce_types
172
176
  @trace_method
@@ -280,8 +284,10 @@ class MessageManager:
280
284
  )
281
285
 
282
286
  message = self._update_message_by_id_impl(message_id, message_update, actor, message)
283
- await message.update_async(db_session=session, actor=actor)
284
- return message.to_pydantic()
287
+ await message.update_async(db_session=session, actor=actor, no_commit=True, no_refresh=True)
288
+ pydantic_message = message.to_pydantic()
289
+ await session.commit()
290
+ return pydantic_message
285
291
 
286
292
  def _update_message_by_id_impl(
287
293
  self, message_id: str, message_update: MessageUpdate, actor: PydanticUser, message: MessageModel
@@ -453,17 +459,23 @@ class MessageManager:
453
459
  if group_id:
454
460
  query = query.filter(MessageModel.group_id == group_id)
455
461
 
456
- # If query_text is provided, filter messages using subquery + json_array_elements.
462
+ # If query_text is provided, filter messages using database-specific JSON search.
457
463
  if query_text:
458
- content_element = func.json_array_elements(MessageModel.content).alias("content_element")
459
- query = query.filter(
460
- exists(
461
- select(1)
462
- .select_from(content_element)
463
- .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
464
- .params(query_text=f"%{query_text}%")
464
+ if settings.database_engine is DatabaseChoice.POSTGRES:
465
+ # PostgreSQL: Use json_array_elements and ILIKE
466
+ content_element = func.json_array_elements(MessageModel.content).alias("content_element")
467
+ query = query.filter(
468
+ exists(
469
+ select(1)
470
+ .select_from(content_element)
471
+ .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
472
+ .params(query_text=f"%{query_text}%")
473
+ )
465
474
  )
466
- )
475
+ else:
476
+ # SQLite: Use JSON_EXTRACT with individual array indices for case-insensitive search
477
+ # Since SQLite doesn't support $[*] syntax, we'll use a different approach
478
+ query = query.filter(text("JSON_EXTRACT(content, '$') LIKE :query_text")).params(query_text=f"%{query_text}%")
467
479
 
468
480
  # If role(s) are provided, filter messages by those roles.
469
481
  if roles:
@@ -512,6 +524,7 @@ class MessageManager:
512
524
  limit: Optional[int] = 50,
513
525
  ascending: bool = True,
514
526
  group_id: Optional[str] = None,
527
+ include_err: Optional[bool] = None,
515
528
  ) -> List[PydanticMessage]:
516
529
  """
517
530
  Most performant query to list messages for an agent by directly querying the Message table.
@@ -531,6 +544,7 @@ class MessageManager:
531
544
  limit: Maximum number of messages to return.
532
545
  ascending: If True, sort by sequence_id ascending; if False, sort descending.
533
546
  group_id: Optional group ID to filter messages by group_id.
547
+ include_err: Optional boolean to include errors and error statuses. Used for debugging only.
534
548
 
535
549
  Returns:
536
550
  List[PydanticMessage]: A list of messages (converted via .to_pydantic()).
@@ -541,7 +555,7 @@ class MessageManager:
541
555
 
542
556
  async with db_registry.async_session() as session:
543
557
  # Permission check: raise if the agent doesn't exist or actor is not allowed.
544
- await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
558
+ await validate_agent_exists_async(session, agent_id, actor)
545
559
 
546
560
  # Build a query that directly filters the Message table by agent_id.
547
561
  query = select(MessageModel).where(MessageModel.agent_id == agent_id)
@@ -550,17 +564,26 @@ class MessageManager:
550
564
  if group_id:
551
565
  query = query.where(MessageModel.group_id == group_id)
552
566
 
553
- # If query_text is provided, filter messages using subquery + json_array_elements.
567
+ if not include_err:
568
+ query = query.where((MessageModel.is_err == False) | (MessageModel.is_err.is_(None)))
569
+
570
+ # If query_text is provided, filter messages using database-specific JSON search.
554
571
  if query_text:
555
- content_element = func.json_array_elements(MessageModel.content).alias("content_element")
556
- query = query.where(
557
- exists(
558
- select(1)
559
- .select_from(content_element)
560
- .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
561
- .params(query_text=f"%{query_text}%")
572
+ if settings.database_engine is DatabaseChoice.POSTGRES:
573
+ # PostgreSQL: Use json_array_elements and ILIKE
574
+ content_element = func.json_array_elements(MessageModel.content).alias("content_element")
575
+ query = query.where(
576
+ exists(
577
+ select(1)
578
+ .select_from(content_element)
579
+ .where(text("content_element->>'type' = 'text' AND content_element->>'text' ILIKE :query_text"))
580
+ .params(query_text=f"%{query_text}%")
581
+ )
562
582
  )
563
- )
583
+ else:
584
+ # SQLite: Use JSON_EXTRACT with individual array indices for case-insensitive search
585
+ # Since SQLite doesn't support $[*] syntax, we'll use a different approach
586
+ query = query.where(text("JSON_EXTRACT(content, '$') LIKE :query_text")).params(query_text=f"%{query_text}%")
564
587
 
565
588
  # If role(s) are provided, filter messages by those roles.
566
589
  if roles:
@@ -611,7 +634,7 @@ class MessageManager:
611
634
  """
612
635
  async with db_registry.async_session() as session:
613
636
  # 1) verify the agent exists and the actor has access
614
- await AgentModel.read_async(db_session=session, identifier=agent_id, actor=actor)
637
+ await validate_agent_exists_async(session, agent_id, actor)
615
638
 
616
639
  # 2) issue a CORE DELETE against the mapped class
617
640
  stmt = (
@@ -476,7 +476,6 @@ class PassageManager:
476
476
  try:
477
477
  # breakup string into passages
478
478
  for text in parse_and_chunk_text(text, embedding_chunk_size):
479
-
480
479
  if agent_state.embedding_config.embedding_endpoint_type != "openai":
481
480
  embedding = embed_model.get_text_embedding(text)
482
481
  else:
@@ -213,7 +213,7 @@ class ProviderManager:
213
213
  provider_type=provider_check.provider_type,
214
214
  api_key=provider_check.api_key,
215
215
  provider_category=ProviderCategory.byok,
216
- secret_key=provider_check.api_secret,
216
+ access_id_key=provider_check.access_id_key, # This contains the access key ID for Bedrock
217
217
  region=provider_check.region,
218
218
  ).cast_to_subtype()
219
219