letta-nightly 0.8.15.dev20250719104256__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.
- letta/__init__.py +1 -1
- letta/agent.py +27 -11
- letta/agents/helpers.py +1 -1
- letta/agents/letta_agent.py +518 -322
- letta/agents/letta_agent_batch.py +1 -2
- letta/agents/voice_agent.py +15 -17
- letta/client/client.py +3 -3
- letta/constants.py +5 -0
- letta/embeddings.py +0 -2
- letta/errors.py +8 -0
- letta/functions/function_sets/base.py +3 -3
- letta/functions/helpers.py +2 -3
- letta/groups/sleeptime_multi_agent.py +0 -1
- letta/helpers/composio_helpers.py +2 -2
- letta/helpers/converters.py +1 -1
- letta/helpers/pinecone_utils.py +8 -0
- letta/helpers/tool_rule_solver.py +13 -18
- letta/llm_api/aws_bedrock.py +16 -2
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/openai_client.py +1 -1
- letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
- letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
- letta/local_llm/utils.py +1 -2
- letta/orm/agent.py +3 -3
- letta/orm/block.py +4 -4
- letta/orm/files_agents.py +0 -1
- letta/orm/identity.py +2 -0
- letta/orm/mcp_server.py +0 -2
- letta/orm/message.py +140 -14
- letta/orm/organization.py +5 -5
- letta/orm/passage.py +4 -4
- letta/orm/source.py +1 -1
- letta/orm/sqlalchemy_base.py +61 -39
- letta/orm/step.py +2 -0
- letta/otel/db_pool_monitoring.py +308 -0
- letta/otel/metric_registry.py +94 -1
- letta/otel/sqlalchemy_instrumentation.py +548 -0
- letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
- letta/otel/tracing.py +37 -1
- letta/schemas/agent.py +0 -3
- letta/schemas/agent_file.py +283 -0
- letta/schemas/block.py +0 -3
- letta/schemas/file.py +28 -26
- letta/schemas/letta_message.py +15 -4
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +31 -26
- letta/schemas/openai/chat_completion_response.py +0 -1
- letta/schemas/providers.py +20 -0
- letta/schemas/source.py +11 -13
- letta/schemas/step.py +12 -0
- letta/schemas/tool.py +0 -4
- letta/serialize_schemas/marshmallow_agent.py +14 -1
- letta/serialize_schemas/marshmallow_block.py +23 -1
- letta/serialize_schemas/marshmallow_message.py +1 -3
- letta/serialize_schemas/marshmallow_tool.py +23 -1
- letta/server/db.py +110 -6
- letta/server/rest_api/app.py +85 -73
- letta/server/rest_api/routers/v1/agents.py +68 -53
- letta/server/rest_api/routers/v1/blocks.py +2 -2
- letta/server/rest_api/routers/v1/jobs.py +3 -0
- letta/server/rest_api/routers/v1/organizations.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +18 -2
- letta/server/rest_api/routers/v1/tools.py +11 -12
- letta/server/rest_api/routers/v1/users.py +1 -1
- letta/server/rest_api/streaming_response.py +13 -5
- letta/server/rest_api/utils.py +8 -25
- letta/server/server.py +11 -4
- letta/server/ws_api/server.py +2 -2
- letta/services/agent_file_manager.py +616 -0
- letta/services/agent_manager.py +133 -46
- letta/services/block_manager.py +38 -17
- letta/services/file_manager.py +106 -21
- letta/services/file_processor/file_processor.py +93 -0
- letta/services/files_agents_manager.py +28 -0
- letta/services/group_manager.py +4 -5
- letta/services/helpers/agent_manager_helper.py +57 -9
- letta/services/identity_manager.py +22 -0
- letta/services/job_manager.py +210 -91
- letta/services/llm_batch_manager.py +9 -6
- letta/services/mcp/stdio_client.py +1 -2
- letta/services/mcp_manager.py +0 -1
- letta/services/message_manager.py +49 -26
- letta/services/passage_manager.py +0 -1
- letta/services/provider_manager.py +1 -1
- letta/services/source_manager.py +114 -5
- letta/services/step_manager.py +36 -4
- letta/services/telemetry_manager.py +9 -2
- letta/services/tool_executor/builtin_tool_executor.py +5 -1
- letta/services/tool_executor/core_tool_executor.py +3 -3
- letta/services/tool_manager.py +95 -20
- letta/services/user_manager.py +4 -12
- letta/settings.py +23 -6
- letta/system.py +1 -1
- letta/utils.py +26 -2
- {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/METADATA +3 -2
- {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/RECORD +99 -94
- {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.15.dev20250719104256.dist-info → letta_nightly-0.8.16.dev20250721070720.dist-info}/entry_points.txt +0 -0
letta/services/job_manager.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
135
|
-
|
181
|
+
# Get the updated metadata for callback
|
182
|
+
final_metadata = job.metadata_
|
183
|
+
result = job.to_pydantic()
|
184
|
+
await session.commit()
|
136
185
|
|
137
|
-
|
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
|
-
|
300
|
+
# build base query
|
301
|
+
query = select(JobModel).where(JobModel.user_id == actor.id).where(JobModel.job_type == job_type)
|
243
302
|
|
244
|
-
#
|
303
|
+
# add status filter if provided
|
245
304
|
if statuses:
|
246
|
-
|
305
|
+
query = query.where(JobModel.status.in_(statuses))
|
247
306
|
|
307
|
+
# add source_id filter if provided
|
248
308
|
if source_id:
|
249
|
-
|
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", "
|
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
|
810
|
+
def _dispatch_callback_sync(self, callback_info: dict) -> dict:
|
689
811
|
"""
|
690
|
-
POST a standard JSON payload to
|
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":
|
696
|
-
"status":
|
697
|
-
"completed_at":
|
698
|
-
"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(
|
826
|
+
resp = post(callback_info["callback_url"], json=payload, timeout=5.0)
|
703
827
|
log_event("POST callback finished")
|
704
|
-
|
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 {
|
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
|
-
|
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,
|
838
|
+
async def _dispatch_callback_async(self, callback_info: dict) -> dict:
|
717
839
|
"""
|
718
|
-
POST a standard JSON payload to
|
840
|
+
POST a standard JSON payload to callback_url and return callback status asynchronously.
|
719
841
|
"""
|
720
|
-
|
721
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
288
|
-
|
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
|
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))
|
letta/services/mcp_manager.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
462
|
+
# If query_text is provided, filter messages using database-specific JSON search.
|
457
463
|
if query_text:
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
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
|
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
|
-
|
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
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
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
|
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
|
-
|
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
|
|