letta-nightly 0.11.7.dev20251006104136__py3-none-any.whl → 0.11.7.dev20251008104128__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. letta/adapters/letta_llm_adapter.py +1 -0
  2. letta/adapters/letta_llm_request_adapter.py +0 -1
  3. letta/adapters/letta_llm_stream_adapter.py +7 -2
  4. letta/adapters/simple_llm_request_adapter.py +88 -0
  5. letta/adapters/simple_llm_stream_adapter.py +192 -0
  6. letta/agents/agent_loop.py +6 -0
  7. letta/agents/ephemeral_summary_agent.py +2 -1
  8. letta/agents/helpers.py +142 -6
  9. letta/agents/letta_agent.py +13 -33
  10. letta/agents/letta_agent_batch.py +2 -4
  11. letta/agents/letta_agent_v2.py +87 -77
  12. letta/agents/letta_agent_v3.py +899 -0
  13. letta/agents/voice_agent.py +2 -6
  14. letta/constants.py +8 -4
  15. letta/errors.py +40 -0
  16. letta/functions/function_sets/base.py +84 -4
  17. letta/functions/function_sets/multi_agent.py +0 -3
  18. letta/functions/schema_generator.py +113 -71
  19. letta/groups/dynamic_multi_agent.py +3 -2
  20. letta/groups/helpers.py +1 -2
  21. letta/groups/round_robin_multi_agent.py +3 -2
  22. letta/groups/sleeptime_multi_agent.py +3 -2
  23. letta/groups/sleeptime_multi_agent_v2.py +1 -1
  24. letta/groups/sleeptime_multi_agent_v3.py +17 -17
  25. letta/groups/supervisor_multi_agent.py +84 -80
  26. letta/helpers/converters.py +3 -0
  27. letta/helpers/message_helper.py +4 -0
  28. letta/helpers/tool_rule_solver.py +92 -5
  29. letta/interfaces/anthropic_streaming_interface.py +409 -0
  30. letta/interfaces/gemini_streaming_interface.py +296 -0
  31. letta/interfaces/openai_streaming_interface.py +752 -1
  32. letta/llm_api/anthropic_client.py +126 -16
  33. letta/llm_api/bedrock_client.py +4 -2
  34. letta/llm_api/deepseek_client.py +4 -1
  35. letta/llm_api/google_vertex_client.py +123 -42
  36. letta/llm_api/groq_client.py +4 -1
  37. letta/llm_api/llm_api_tools.py +11 -4
  38. letta/llm_api/llm_client_base.py +6 -2
  39. letta/llm_api/openai.py +32 -2
  40. letta/llm_api/openai_client.py +423 -18
  41. letta/llm_api/xai_client.py +4 -1
  42. letta/main.py +9 -5
  43. letta/memory.py +1 -0
  44. letta/orm/__init__.py +1 -1
  45. letta/orm/agent.py +10 -0
  46. letta/orm/block.py +7 -16
  47. letta/orm/blocks_agents.py +8 -2
  48. letta/orm/files_agents.py +2 -0
  49. letta/orm/job.py +7 -5
  50. letta/orm/mcp_oauth.py +1 -0
  51. letta/orm/message.py +21 -6
  52. letta/orm/organization.py +2 -0
  53. letta/orm/provider.py +6 -2
  54. letta/orm/run.py +71 -0
  55. letta/orm/sandbox_config.py +7 -1
  56. letta/orm/sqlalchemy_base.py +0 -306
  57. letta/orm/step.py +6 -5
  58. letta/orm/step_metrics.py +5 -5
  59. letta/otel/tracing.py +28 -3
  60. letta/plugins/defaults.py +4 -4
  61. letta/prompts/system_prompts/__init__.py +2 -0
  62. letta/prompts/system_prompts/letta_v1.py +25 -0
  63. letta/schemas/agent.py +3 -2
  64. letta/schemas/agent_file.py +9 -3
  65. letta/schemas/block.py +23 -10
  66. letta/schemas/enums.py +21 -2
  67. letta/schemas/job.py +17 -4
  68. letta/schemas/letta_message_content.py +71 -2
  69. letta/schemas/letta_stop_reason.py +5 -5
  70. letta/schemas/llm_config.py +53 -3
  71. letta/schemas/memory.py +1 -1
  72. letta/schemas/message.py +504 -117
  73. letta/schemas/openai/responses_request.py +64 -0
  74. letta/schemas/providers/__init__.py +2 -0
  75. letta/schemas/providers/anthropic.py +16 -0
  76. letta/schemas/providers/ollama.py +115 -33
  77. letta/schemas/providers/openrouter.py +52 -0
  78. letta/schemas/providers/vllm.py +2 -1
  79. letta/schemas/run.py +48 -42
  80. letta/schemas/step.py +2 -2
  81. letta/schemas/step_metrics.py +1 -1
  82. letta/schemas/tool.py +15 -107
  83. letta/schemas/tool_rule.py +88 -5
  84. letta/serialize_schemas/marshmallow_agent.py +1 -0
  85. letta/server/db.py +86 -408
  86. letta/server/rest_api/app.py +61 -10
  87. letta/server/rest_api/dependencies.py +14 -0
  88. letta/server/rest_api/redis_stream_manager.py +19 -8
  89. letta/server/rest_api/routers/v1/agents.py +364 -292
  90. letta/server/rest_api/routers/v1/blocks.py +14 -20
  91. letta/server/rest_api/routers/v1/identities.py +45 -110
  92. letta/server/rest_api/routers/v1/internal_templates.py +21 -0
  93. letta/server/rest_api/routers/v1/jobs.py +23 -6
  94. letta/server/rest_api/routers/v1/messages.py +1 -1
  95. letta/server/rest_api/routers/v1/runs.py +126 -85
  96. letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
  97. letta/server/rest_api/routers/v1/tools.py +281 -594
  98. letta/server/rest_api/routers/v1/voice.py +1 -1
  99. letta/server/rest_api/streaming_response.py +29 -29
  100. letta/server/rest_api/utils.py +122 -64
  101. letta/server/server.py +160 -887
  102. letta/services/agent_manager.py +236 -919
  103. letta/services/agent_serialization_manager.py +16 -0
  104. letta/services/archive_manager.py +0 -100
  105. letta/services/block_manager.py +211 -168
  106. letta/services/file_manager.py +1 -1
  107. letta/services/files_agents_manager.py +24 -33
  108. letta/services/group_manager.py +0 -142
  109. letta/services/helpers/agent_manager_helper.py +7 -2
  110. letta/services/helpers/run_manager_helper.py +85 -0
  111. letta/services/job_manager.py +96 -411
  112. letta/services/lettuce/__init__.py +6 -0
  113. letta/services/lettuce/lettuce_client_base.py +86 -0
  114. letta/services/mcp_manager.py +38 -6
  115. letta/services/message_manager.py +165 -362
  116. letta/services/organization_manager.py +0 -36
  117. letta/services/passage_manager.py +0 -345
  118. letta/services/provider_manager.py +0 -80
  119. letta/services/run_manager.py +301 -0
  120. letta/services/sandbox_config_manager.py +0 -234
  121. letta/services/step_manager.py +62 -39
  122. letta/services/summarizer/summarizer.py +9 -7
  123. letta/services/telemetry_manager.py +0 -16
  124. letta/services/tool_executor/builtin_tool_executor.py +35 -0
  125. letta/services/tool_executor/core_tool_executor.py +397 -2
  126. letta/services/tool_executor/files_tool_executor.py +3 -3
  127. letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
  128. letta/services/tool_executor/tool_execution_manager.py +6 -8
  129. letta/services/tool_executor/tool_executor_base.py +3 -3
  130. letta/services/tool_manager.py +85 -339
  131. letta/services/tool_sandbox/base.py +24 -13
  132. letta/services/tool_sandbox/e2b_sandbox.py +16 -1
  133. letta/services/tool_schema_generator.py +123 -0
  134. letta/services/user_manager.py +0 -99
  135. letta/settings.py +20 -4
  136. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
  137. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
  138. letta/agents/temporal/activities/__init__.py +0 -4
  139. letta/agents/temporal/activities/example_activity.py +0 -7
  140. letta/agents/temporal/activities/prepare_messages.py +0 -10
  141. letta/agents/temporal/temporal_agent_workflow.py +0 -56
  142. letta/agents/temporal/types.py +0 -25
  143. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
  144. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
  145. {letta_nightly-0.11.7.dev20251006104136.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/licenses/LICENSE +0 -0
@@ -10,7 +10,6 @@ from letta.helpers.datetime_helpers import get_utc_time
10
10
  from letta.log import get_logger
11
11
  from letta.orm.errors import NoResultFound
12
12
  from letta.orm.job import Job as JobModel
13
- from letta.orm.job_messages import JobMessage
14
13
  from letta.orm.message import Message as MessageModel
15
14
  from letta.orm.sqlalchemy_base import AccessType
16
15
  from letta.orm.step import Step, Step as StepModel
@@ -25,6 +24,7 @@ from letta.schemas.step import Step as PydanticStep
25
24
  from letta.schemas.usage import LettaUsageStatistics
26
25
  from letta.schemas.user import User as PydanticUser
27
26
  from letta.server.db import db_registry
27
+ from letta.services.helpers.agent_manager_helper import validate_agent_exists_async
28
28
  from letta.utils import enforce_types
29
29
 
30
30
  logger = get_logger(__name__)
@@ -33,21 +33,6 @@ logger = get_logger(__name__)
33
33
  class JobManager:
34
34
  """Manager class to handle business logic related to Jobs."""
35
35
 
36
- @enforce_types
37
- @trace_method
38
- def create_job(
39
- self, pydantic_job: Union[PydanticJob, PydanticRun, PydanticBatchJob], actor: PydanticUser
40
- ) -> Union[PydanticJob, PydanticRun, PydanticBatchJob]:
41
- """Create a new job based on the JobCreate schema."""
42
- with db_registry.session() as session:
43
- # Associate the job with the user
44
- pydantic_job.user_id = actor.id
45
- job_data = pydantic_job.model_dump(to_orm=True)
46
- job = JobModel(**job_data)
47
- job.organization_id = actor.organization_id
48
- job.create(session, actor=actor) # Save job in the database
49
- return job.to_pydantic()
50
-
51
36
  @enforce_types
52
37
  @trace_method
53
38
  async def create_job_async(
@@ -57,76 +42,31 @@ class JobManager:
57
42
  async with db_registry.async_session() as session:
58
43
  # Associate the job with the user
59
44
  pydantic_job.user_id = actor.id
45
+
46
+ # Get agent_id if present
47
+ agent_id = getattr(pydantic_job, "agent_id", None)
48
+
49
+ # Verify agent exists before creating the job
50
+ if agent_id:
51
+ await validate_agent_exists_async(session, agent_id, actor)
52
+
60
53
  job_data = pydantic_job.model_dump(to_orm=True)
54
+ # Remove agent_id from job_data as it's not a field in the Job ORM model
55
+ job_data.pop("agent_id", None)
61
56
  job = JobModel(**job_data)
62
57
  job.organization_id = actor.organization_id
63
58
  job = await job.create_async(session, actor=actor, no_commit=True, no_refresh=True) # Save job in the database
64
- result = job.to_pydantic()
65
- await session.commit()
66
- return result
67
59
 
68
- @enforce_types
69
- @trace_method
70
- def update_job_by_id(self, job_id: str, job_update: JobUpdate, actor: PydanticUser) -> PydanticJob:
71
- """Update a job by its ID with the given JobUpdate object."""
72
- # First check if we need to dispatch a callback
73
- needs_callback = False
74
- callback_url = None
75
- with db_registry.session() as session:
76
- job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
77
- not_completed_before = not bool(job.completed_at)
78
-
79
- # Check if we'll need to dispatch callback
80
- if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before and job.callback_url:
81
- needs_callback = True
82
- callback_url = job.callback_url
83
-
84
- # Update the job first to get the final metadata
85
- with db_registry.session() as session:
86
- job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
87
- not_completed_before = not bool(job.completed_at)
88
-
89
- # Update job attributes with only the fields that were explicitly set
90
- update_data = job_update.model_dump(to_orm=True, exclude_unset=True, exclude_none=True)
91
-
92
- # Automatically update the completion timestamp if status is set to 'completed'
93
- for key, value in update_data.items():
94
- # Ensure completed_at is timezone-naive for database compatibility
95
- if key == "completed_at" and value is not None and hasattr(value, "replace"):
96
- value = value.replace(tzinfo=None)
97
- setattr(job, key, value)
98
-
99
- if job_update.status in {JobStatus.completed, JobStatus.failed} and not_completed_before:
100
- job.completed_at = get_utc_time().replace(tzinfo=None)
101
-
102
- # Save the updated job to the database first
103
- job = job.update(db_session=session, actor=actor)
60
+ await session.commit()
104
61
 
105
- # Get the updated metadata for callback
106
- final_metadata = job.metadata_
107
- result = job.to_pydantic()
62
+ # Convert to pydantic first, then add agent_id if needed
63
+ result = super(JobModel, job).to_pydantic()
108
64
 
109
- # Dispatch callback outside of database session if needed
110
- if needs_callback:
111
- callback_info = {
112
- "job_id": job_id,
113
- "callback_url": callback_url,
114
- "status": job_update.status,
115
- "completed_at": get_utc_time().replace(tzinfo=None),
116
- "metadata": final_metadata,
117
- }
118
- callback_result = self._dispatch_callback_sync(callback_info)
119
-
120
- # Update callback status in a separate transaction
121
- with db_registry.session() as session:
122
- job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
123
- job.callback_sent_at = callback_result["callback_sent_at"]
124
- job.callback_status_code = callback_result.get("callback_status_code")
125
- job.callback_error = callback_result.get("callback_error")
126
- job.update(db_session=session, actor=actor)
127
- result = job.to_pydantic()
65
+ # Add back the agent_id field to the result if it was present
66
+ if agent_id and isinstance(pydantic_job, PydanticRun):
67
+ result.agent_id = agent_id
128
68
 
129
- return result
69
+ return result
130
70
 
131
71
  @enforce_types
132
72
  @trace_method
@@ -245,15 +185,6 @@ class JobManager:
245
185
  logger.error(f"Failed to safely update job status for job {job_id}: {e}")
246
186
  return False
247
187
 
248
- @enforce_types
249
- @trace_method
250
- def get_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
251
- """Fetch a job by its ID."""
252
- with db_registry.session() as session:
253
- # Retrieve job by ID using the Job model's read method
254
- job = JobModel.read(db_session=session, identifier=job_id, actor=actor, access_type=AccessType.USER)
255
- return job.to_pydantic()
256
-
257
188
  @enforce_types
258
189
  @trace_method
259
190
  async def get_job_by_id_async(self, job_id: str, actor: PydanticUser) -> PydanticJob:
@@ -263,41 +194,6 @@ class JobManager:
263
194
  job = await JobModel.read_async(db_session=session, identifier=job_id, actor=actor, access_type=AccessType.USER)
264
195
  return job.to_pydantic()
265
196
 
266
- @enforce_types
267
- @trace_method
268
- def list_jobs(
269
- self,
270
- actor: PydanticUser,
271
- before: Optional[str] = None,
272
- after: Optional[str] = None,
273
- limit: Optional[int] = 50,
274
- statuses: Optional[List[JobStatus]] = None,
275
- job_type: JobType = JobType.JOB,
276
- ascending: bool = True,
277
- stop_reason: Optional[StopReasonType] = None,
278
- ) -> List[PydanticJob]:
279
- """List all jobs with optional pagination and status filter."""
280
- with db_registry.session() as session:
281
- filter_kwargs = {"user_id": actor.id, "job_type": job_type}
282
-
283
- # Add status filter if provided
284
- if statuses:
285
- filter_kwargs["status"] = statuses
286
-
287
- # Add stop_reason filter if provided
288
- if stop_reason is not None:
289
- filter_kwargs["stop_reason"] = stop_reason
290
-
291
- jobs = JobModel.list(
292
- db_session=session,
293
- before=before,
294
- after=after,
295
- limit=limit,
296
- ascending=ascending,
297
- **filter_kwargs,
298
- )
299
- return [job.to_pydantic() for job in jobs]
300
-
301
197
  @enforce_types
302
198
  @trace_method
303
199
  async def list_jobs_async(
@@ -311,6 +207,9 @@ class JobManager:
311
207
  ascending: bool = True,
312
208
  source_id: Optional[str] = None,
313
209
  stop_reason: Optional[StopReasonType] = None,
210
+ # agent_id: Optional[str] = None,
211
+ agent_ids: Optional[List[str]] = None,
212
+ background: Optional[bool] = None,
314
213
  ) -> List[PydanticJob]:
315
214
  """List all jobs with optional pagination and status filter."""
316
215
  from sqlalchemy import and_, or_, select
@@ -323,16 +222,20 @@ class JobManager:
323
222
  if statuses:
324
223
  query = query.where(JobModel.status.in_(statuses))
325
224
 
225
+ # add stop_reason filter if provided
226
+ if stop_reason is not None:
227
+ query = query.where(JobModel.stop_reason == stop_reason)
228
+
229
+ # add background filter if provided
230
+ if background is not None:
231
+ query = query.where(JobModel.background == background)
232
+
326
233
  # add source_id filter if provided
327
234
  if source_id:
328
235
  column = getattr(JobModel, "metadata_")
329
236
  column = column.op("->>")("source_id")
330
237
  query = query.where(column == source_id)
331
238
 
332
- # add stop_reason filter if provided
333
- if stop_reason is not None:
334
- query = query.where(JobModel.stop_reason == stop_reason)
335
-
336
239
  # handle cursor-based pagination
337
240
  if before or after:
338
241
  # get cursor objects
@@ -396,15 +299,6 @@ class JobManager:
396
299
 
397
300
  return [job.to_pydantic() for job in jobs]
398
301
 
399
- @enforce_types
400
- @trace_method
401
- def delete_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
402
- """Delete a job by its ID."""
403
- with db_registry.session() as session:
404
- job = self._verify_job_access(session=session, job_id=job_id, actor=actor)
405
- job.hard_delete(db_session=session, actor=actor)
406
- return job.to_pydantic()
407
-
408
302
  @enforce_types
409
303
  @trace_method
410
304
  async def delete_job_by_id_async(self, job_id: str, actor: PydanticUser) -> PydanticJob:
@@ -416,234 +310,7 @@ class JobManager:
416
310
 
417
311
  @enforce_types
418
312
  @trace_method
419
- def get_job_messages(
420
- self,
421
- job_id: str,
422
- actor: PydanticUser,
423
- before: Optional[str] = None,
424
- after: Optional[str] = None,
425
- limit: Optional[int] = 100,
426
- role: Optional[MessageRole] = None,
427
- ascending: bool = True,
428
- ) -> List[PydanticMessage]:
429
- """
430
- Get all messages associated with a job.
431
-
432
- Args:
433
- job_id: The ID of the job to get messages for
434
- actor: The user making the request
435
- before: Cursor for pagination
436
- after: Cursor for pagination
437
- limit: Maximum number of messages to return
438
- role: Optional filter for message role
439
- ascending: Optional flag to sort in ascending order
440
-
441
- Returns:
442
- List of messages associated with the job
443
-
444
- Raises:
445
- NoResultFound: If the job does not exist or user does not have access
446
- """
447
- with db_registry.session() as session:
448
- # Build filters
449
- filters = {}
450
- if role is not None:
451
- filters["role"] = role
452
-
453
- # Get messages
454
- messages = MessageModel.list(
455
- db_session=session,
456
- before=before,
457
- after=after,
458
- ascending=ascending,
459
- limit=limit,
460
- actor=actor,
461
- join_model=JobMessage,
462
- join_conditions=[MessageModel.id == JobMessage.message_id, JobMessage.job_id == job_id],
463
- **filters,
464
- )
465
-
466
- return [message.to_pydantic() for message in messages]
467
-
468
- @enforce_types
469
- @trace_method
470
- def get_job_steps(
471
- self,
472
- job_id: str,
473
- actor: PydanticUser,
474
- before: Optional[str] = None,
475
- after: Optional[str] = None,
476
- limit: Optional[int] = 100,
477
- ascending: bool = True,
478
- ) -> List[PydanticStep]:
479
- """
480
- Get all steps associated with a job.
481
-
482
- Args:
483
- job_id: The ID of the job to get steps for
484
- actor: The user making the request
485
- before: Cursor for pagination
486
- after: Cursor for pagination
487
- limit: Maximum number of steps to return
488
- ascending: Optional flag to sort in ascending order
489
-
490
- Returns:
491
- List of steps associated with the job
492
-
493
- Raises:
494
- NoResultFound: If the job does not exist or user does not have access
495
- """
496
- with db_registry.session() as session:
497
- # Build filters
498
- filters = {}
499
- filters["job_id"] = job_id
500
-
501
- # Get steps
502
- steps = StepModel.list(
503
- db_session=session,
504
- before=before,
505
- after=after,
506
- ascending=ascending,
507
- limit=limit,
508
- actor=actor,
509
- **filters,
510
- )
511
-
512
- return [step.to_pydantic() for step in steps]
513
-
514
- @enforce_types
515
- @trace_method
516
- def add_message_to_job(self, job_id: str, message_id: str, actor: PydanticUser) -> None:
517
- """
518
- Associate a message with a job by creating a JobMessage record.
519
- Each message can only be associated with one job.
520
-
521
- Args:
522
- job_id: The ID of the job
523
- message_id: The ID of the message to associate
524
- actor: The user making the request
525
-
526
- Raises:
527
- NoResultFound: If the job does not exist or user does not have access
528
- """
529
- with db_registry.session() as session:
530
- # First verify job exists and user has access
531
- self._verify_job_access(session, job_id, actor, access=["write"])
532
-
533
- # Create new JobMessage association
534
- job_message = JobMessage(job_id=job_id, message_id=message_id)
535
- session.add(job_message)
536
- session.commit()
537
-
538
- @enforce_types
539
- @trace_method
540
- async def add_messages_to_job_async(self, job_id: str, message_ids: List[str], actor: PydanticUser) -> None:
541
- """
542
- Associate a message with a job by creating a JobMessage record.
543
- Each message can only be associated with one job.
544
-
545
- Args:
546
- job_id: The ID of the job
547
- message_id: The ID of the message to associate
548
- actor: The user making the request
549
-
550
- Raises:
551
- NoResultFound: If the job does not exist or user does not have access
552
- """
553
- if not message_ids:
554
- return
555
-
556
- async with db_registry.async_session() as session:
557
- # First verify job exists and user has access
558
- await self._verify_job_access_async(session, job_id, actor, access=["write"])
559
-
560
- # Create new JobMessage associations
561
- job_messages = [JobMessage(job_id=job_id, message_id=message_id) for message_id in message_ids]
562
- session.add_all(job_messages)
563
- await session.commit()
564
-
565
- @enforce_types
566
- @trace_method
567
- def get_job_usage(self, job_id: str, actor: PydanticUser) -> LettaUsageStatistics:
568
- """
569
- Get usage statistics for a job.
570
-
571
- Args:
572
- job_id: The ID of the job
573
- actor: The user making the request
574
-
575
- Returns:
576
- Usage statistics for the job
577
-
578
- Raises:
579
- NoResultFound: If the job does not exist or user does not have access
580
- """
581
- with db_registry.session() as session:
582
- # First verify job exists and user has access
583
- self._verify_job_access(session, job_id, actor)
584
-
585
- # Get the latest usage statistics for the job
586
- latest_stats = session.query(Step).filter(Step.job_id == job_id).order_by(Step.created_at.desc()).all()
587
-
588
- if not latest_stats:
589
- return LettaUsageStatistics(
590
- completion_tokens=0,
591
- prompt_tokens=0,
592
- total_tokens=0,
593
- step_count=0,
594
- )
595
-
596
- return LettaUsageStatistics(
597
- completion_tokens=reduce(add, (step.completion_tokens or 0 for step in latest_stats), 0),
598
- prompt_tokens=reduce(add, (step.prompt_tokens or 0 for step in latest_stats), 0),
599
- total_tokens=reduce(add, (step.total_tokens or 0 for step in latest_stats), 0),
600
- step_count=len(latest_stats),
601
- )
602
-
603
- @enforce_types
604
- @trace_method
605
- def add_job_usage(
606
- self,
607
- job_id: str,
608
- usage: LettaUsageStatistics,
609
- step_id: Optional[str] = None,
610
- actor: PydanticUser = None,
611
- ) -> None:
612
- """
613
- Add usage statistics for a job.
614
-
615
- Args:
616
- job_id: The ID of the job
617
- usage: Usage statistics for the job
618
- step_id: Optional ID of the specific step within the job
619
- actor: The user making the request
620
-
621
- Raises:
622
- NoResultFound: If the job does not exist or user does not have access
623
- """
624
- with db_registry.session() as session:
625
- # First verify job exists and user has access
626
- self._verify_job_access(session, job_id, actor, access=["write"])
627
-
628
- # Manually log step with usage data
629
- # TODO(@caren): log step under the hood and remove this
630
- usage_stats = Step(
631
- job_id=job_id,
632
- completion_tokens=usage.completion_tokens,
633
- prompt_tokens=usage.prompt_tokens,
634
- total_tokens=usage.total_tokens,
635
- step_count=usage.step_count,
636
- step_id=step_id,
637
- )
638
- if actor:
639
- usage_stats._set_created_and_updated_by_fields(actor.id)
640
-
641
- session.add(usage_stats)
642
- session.commit()
643
-
644
- @enforce_types
645
- @trace_method
646
- def get_run_messages(
313
+ async def get_run_messages(
647
314
  self,
648
315
  run_id: str,
649
316
  actor: PydanticUser,
@@ -672,7 +339,7 @@ class JobManager:
672
339
  Raises:
673
340
  NoResultFound: If the job does not exist or user does not have access
674
341
  """
675
- messages = self.get_job_messages(
342
+ messages = await self.get_job_messages(
676
343
  job_id=run_id,
677
344
  actor=actor,
678
345
  before=before,
@@ -682,7 +349,7 @@ class JobManager:
682
349
  ascending=ascending,
683
350
  )
684
351
 
685
- request_config = self._get_run_request_config(run_id)
352
+ request_config = await self._get_run_request_config(run_id)
686
353
  print("request_config", request_config)
687
354
 
688
355
  messages = PydanticMessage.to_letta_messages_from_list(
@@ -700,7 +367,7 @@ class JobManager:
700
367
 
701
368
  @enforce_types
702
369
  @trace_method
703
- def get_step_messages(
370
+ async def get_step_messages(
704
371
  self,
705
372
  run_id: str,
706
373
  actor: PydanticUser,
@@ -729,7 +396,7 @@ class JobManager:
729
396
  Raises:
730
397
  NoResultFound: If the job does not exist or user does not have access
731
398
  """
732
- messages = self.get_job_messages(
399
+ messages = await self.get_job_messages(
733
400
  job_id=run_id,
734
401
  actor=actor,
735
402
  before=before,
@@ -739,7 +406,7 @@ class JobManager:
739
406
  ascending=ascending,
740
407
  )
741
408
 
742
- request_config = self._get_run_request_config(run_id)
409
+ request_config = await self._get_run_request_config(run_id)
743
410
 
744
411
  messages = PydanticMessage.to_letta_messages_from_list(
745
412
  messages=messages,
@@ -750,34 +417,6 @@ class JobManager:
750
417
 
751
418
  return messages
752
419
 
753
- def _verify_job_access(
754
- self,
755
- session: Session,
756
- job_id: str,
757
- actor: PydanticUser,
758
- access: List[Literal["read", "write", "admin"]] = ["read"],
759
- ) -> JobModel:
760
- """
761
- Verify that a job exists and the user has the required access.
762
-
763
- Args:
764
- session: The database session
765
- job_id: The ID of the job to verify
766
- actor: The user making the request
767
-
768
- Returns:
769
- The job if it exists and the user has access
770
-
771
- Raises:
772
- NoResultFound: If the job does not exist or user does not have access
773
- """
774
- job_query = select(JobModel).where(JobModel.id == job_id)
775
- job_query = JobModel.apply_access_predicate(job_query, actor, access, AccessType.USER)
776
- job = session.execute(job_query).scalar_one_or_none()
777
- if not job:
778
- raise NoResultFound(f"Job with id {job_id} does not exist or user does not have access")
779
- return job
780
-
781
420
  async def _verify_job_access_async(
782
421
  self,
783
422
  session: Session,
@@ -807,21 +446,6 @@ class JobManager:
807
446
  raise NoResultFound(f"Job with id {job_id} does not exist or user does not have access")
808
447
  return job
809
448
 
810
- def _get_run_request_config(self, run_id: str) -> LettaRequestConfig:
811
- """
812
- Get the request config for a job.
813
-
814
- Args:
815
- job_id: The ID of the job to get messages for
816
-
817
- Returns:
818
- The request config for the job
819
- """
820
- with db_registry.session() as session:
821
- job = session.query(JobModel).filter(JobModel.id == run_id).first()
822
- request_config = job.request_config or LettaRequestConfig()
823
- return request_config
824
-
825
449
  @enforce_types
826
450
  async def record_ttft(self, job_id: str, ttft_ns: int, actor: PydanticUser) -> None:
827
451
  """Record time to first token for a run"""
@@ -902,3 +526,64 @@ class JobManager:
902
526
  # Continue silently - callback failures should not affect job completion
903
527
  finally:
904
528
  return result
529
+
530
+ @enforce_types
531
+ @trace_method
532
+ async def get_job_steps(
533
+ self,
534
+ job_id: str,
535
+ actor: PydanticUser,
536
+ before: Optional[str] = None,
537
+ after: Optional[str] = None,
538
+ limit: Optional[int] = 100,
539
+ ascending: bool = True,
540
+ ) -> List[PydanticStep]:
541
+ """
542
+ Get all steps associated with a job.
543
+
544
+ Args:
545
+ job_id: The ID of the job to get steps for
546
+ actor: The user making the request
547
+ before: Cursor for pagination
548
+ after: Cursor for pagination
549
+ limit: Maximum number of steps to return
550
+ ascending: Optional flag to sort in ascending order
551
+
552
+ Returns:
553
+ List of steps associated with the job
554
+
555
+ Raises:
556
+ NoResultFound: If the job does not exist or user does not have access
557
+ """
558
+ async with db_registry.async_session() as session:
559
+ # Build filters
560
+ filters = {}
561
+ filters["job_id"] = job_id
562
+
563
+ # Get steps
564
+ steps = StepModel.list_async(
565
+ db_session=session,
566
+ before=before,
567
+ after=after,
568
+ ascending=ascending,
569
+ limit=limit,
570
+ actor=actor,
571
+ **filters,
572
+ )
573
+
574
+ return [step.to_pydantic() for step in steps]
575
+
576
+ async def _get_run_request_config(self, run_id: str) -> LettaRequestConfig:
577
+ """
578
+ Get the request config for a job.
579
+
580
+ Args:
581
+ job_id: The ID of the job to get messages for
582
+
583
+ Returns:
584
+ The request config for the job
585
+ """
586
+ async with db_registry.async_session() as session:
587
+ job = await JobModel.read_async(db_session=session, identifier=run_id)
588
+ request_config = job.request_config or LettaRequestConfig()
589
+ return request_config
@@ -0,0 +1,6 @@
1
+ try:
2
+ from .lettuce_client import LettuceClient
3
+ except ImportError:
4
+ from .lettuce_client_base import LettuceClient
5
+
6
+ __all__ = ["LettuceClient"]