letta-nightly 0.6.9.dev20250116104035__py3-none-any.whl → 0.6.9.dev20250117104025__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.

Potentially problematic release.


This version of letta-nightly might be problematic. Click here for more details.

Files changed (44) hide show
  1. letta/__init__.py +1 -0
  2. letta/agent.py +24 -0
  3. letta/client/client.py +274 -11
  4. letta/constants.py +5 -0
  5. letta/functions/function_sets/multi_agent.py +96 -0
  6. letta/functions/helpers.py +105 -1
  7. letta/functions/schema_generator.py +8 -0
  8. letta/llm_api/openai.py +18 -2
  9. letta/local_llm/utils.py +4 -0
  10. letta/orm/__init__.py +1 -0
  11. letta/orm/enums.py +6 -0
  12. letta/orm/job.py +24 -2
  13. letta/orm/job_messages.py +33 -0
  14. letta/orm/job_usage_statistics.py +30 -0
  15. letta/orm/message.py +10 -0
  16. letta/orm/sqlalchemy_base.py +28 -4
  17. letta/orm/tool.py +0 -3
  18. letta/schemas/agent.py +10 -4
  19. letta/schemas/job.py +2 -0
  20. letta/schemas/letta_base.py +6 -1
  21. letta/schemas/letta_request.py +6 -4
  22. letta/schemas/llm_config.py +1 -1
  23. letta/schemas/message.py +2 -4
  24. letta/schemas/providers.py +1 -1
  25. letta/schemas/run.py +61 -0
  26. letta/schemas/tool.py +9 -17
  27. letta/server/rest_api/interface.py +3 -0
  28. letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +6 -12
  29. letta/server/rest_api/routers/v1/__init__.py +4 -0
  30. letta/server/rest_api/routers/v1/agents.py +47 -151
  31. letta/server/rest_api/routers/v1/runs.py +137 -0
  32. letta/server/rest_api/routers/v1/tags.py +27 -0
  33. letta/server/rest_api/utils.py +5 -3
  34. letta/server/server.py +139 -2
  35. letta/services/agent_manager.py +101 -6
  36. letta/services/job_manager.py +274 -9
  37. letta/services/tool_execution_sandbox.py +1 -1
  38. letta/services/tool_manager.py +30 -25
  39. letta/utils.py +3 -4
  40. {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/METADATA +4 -3
  41. {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/RECORD +44 -38
  42. {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/LICENSE +0 -0
  43. {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/WHEEL +0 -0
  44. {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/entry_points.txt +0 -0
letta/server/server.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # inspecting tools
2
+ import asyncio
2
3
  import os
3
4
  import traceback
4
5
  import warnings
@@ -9,6 +10,7 @@ from typing import Callable, Dict, List, Optional, Tuple, Union
9
10
  from composio.client import Composio
10
11
  from composio.client.collections import ActionModel, AppModel
11
12
  from fastapi import HTTPException
13
+ from fastapi.responses import StreamingResponse
12
14
 
13
15
  import letta.constants as constants
14
16
  import letta.server.utils as server_utils
@@ -30,10 +32,11 @@ from letta.schemas.block import BlockUpdate
30
32
  from letta.schemas.embedding_config import EmbeddingConfig
31
33
 
32
34
  # openai schemas
33
- from letta.schemas.enums import JobStatus
35
+ from letta.schemas.enums import JobStatus, MessageStreamStatus
34
36
  from letta.schemas.environment_variables import SandboxEnvironmentVariableCreate
35
37
  from letta.schemas.job import Job, JobUpdate
36
- from letta.schemas.letta_message import LettaMessage, ToolReturnMessage
38
+ from letta.schemas.letta_message import LegacyLettaMessage, LettaMessage, ToolReturnMessage
39
+ from letta.schemas.letta_response import LettaResponse
37
40
  from letta.schemas.llm_config import LLMConfig
38
41
  from letta.schemas.memory import ArchivalMemorySummary, ContextWindowOverview, Memory, RecallMemorySummary
39
42
  from letta.schemas.message import Message, MessageCreate, MessageRole, MessageUpdate
@@ -57,6 +60,8 @@ from letta.schemas.source import Source
57
60
  from letta.schemas.tool import Tool
58
61
  from letta.schemas.usage import LettaUsageStatistics
59
62
  from letta.schemas.user import User
63
+ from letta.server.rest_api.interface import StreamingServerInterface
64
+ from letta.server.rest_api.utils import sse_async_generator
60
65
  from letta.services.agent_manager import AgentManager
61
66
  from letta.services.block_manager import BlockManager
62
67
  from letta.services.job_manager import JobManager
@@ -425,12 +430,17 @@ class SyncServer(Server):
425
430
  token_streaming = letta_agent.interface.streaming_mode if hasattr(letta_agent.interface, "streaming_mode") else False
426
431
 
427
432
  logger.debug(f"Starting agent step")
433
+ if interface:
434
+ metadata = interface.metadata if hasattr(interface, "metadata") else None
435
+ else:
436
+ metadata = None
428
437
  usage_stats = letta_agent.step(
429
438
  messages=input_messages,
430
439
  chaining=self.chaining,
431
440
  max_chaining_steps=self.max_chaining_steps,
432
441
  stream=token_streaming,
433
442
  skip_verify=True,
443
+ metadata=metadata,
434
444
  )
435
445
 
436
446
  except Exception as e:
@@ -687,6 +697,7 @@ class SyncServer(Server):
687
697
  wrap_user_message: bool = True,
688
698
  wrap_system_message: bool = True,
689
699
  interface: Union[AgentInterface, None] = None, # needed to getting responses
700
+ metadata: Optional[dict] = None, # Pass through metadata to interface
690
701
  ) -> LettaUsageStatistics:
691
702
  """Send a list of messages to the agent
692
703
 
@@ -732,6 +743,10 @@ class SyncServer(Server):
732
743
  else:
733
744
  raise ValueError(f"All messages must be of type Message or MessageCreate, got {[type(message) for message in messages]}")
734
745
 
746
+ # Store metadata in interface if provided
747
+ if metadata and hasattr(interface, "metadata"):
748
+ interface.metadata = metadata
749
+
735
750
  # Run the agent state forward
736
751
  return self._step(actor=actor, agent_id=agent_id, input_messages=message_objects, interface=interface)
737
752
 
@@ -1183,3 +1198,125 @@ class SyncServer(Server):
1183
1198
  def get_composio_actions_from_app_name(self, composio_app_name: str, api_key: Optional[str] = None) -> List["ActionModel"]:
1184
1199
  actions = self.get_composio_client(api_key=api_key).actions.get(apps=[composio_app_name])
1185
1200
  return actions
1201
+
1202
+ async def send_message_to_agent(
1203
+ self,
1204
+ agent_id: str,
1205
+ actor: User,
1206
+ # role: MessageRole,
1207
+ messages: Union[List[Message], List[MessageCreate]],
1208
+ stream_steps: bool,
1209
+ stream_tokens: bool,
1210
+ # related to whether or not we return `LettaMessage`s or `Message`s
1211
+ chat_completion_mode: bool = False,
1212
+ timestamp: Optional[datetime] = None,
1213
+ # Support for AssistantMessage
1214
+ use_assistant_message: bool = True,
1215
+ assistant_message_tool_name: str = constants.DEFAULT_MESSAGE_TOOL,
1216
+ assistant_message_tool_kwarg: str = constants.DEFAULT_MESSAGE_TOOL_KWARG,
1217
+ metadata: Optional[dict] = None,
1218
+ ) -> Union[StreamingResponse, LettaResponse]:
1219
+ """Split off into a separate function so that it can be imported in the /chat/completion proxy."""
1220
+
1221
+ # TODO: @charles is this the correct way to handle?
1222
+ include_final_message = True
1223
+
1224
+ if not stream_steps and stream_tokens:
1225
+ raise HTTPException(status_code=400, detail="stream_steps must be 'true' if stream_tokens is 'true'")
1226
+
1227
+ # For streaming response
1228
+ try:
1229
+
1230
+ # TODO: move this logic into server.py
1231
+
1232
+ # Get the generator object off of the agent's streaming interface
1233
+ # This will be attached to the POST SSE request used under-the-hood
1234
+ letta_agent = self.load_agent(agent_id=agent_id, actor=actor)
1235
+
1236
+ # Disable token streaming if not OpenAI
1237
+ # TODO: cleanup this logic
1238
+ llm_config = letta_agent.agent_state.llm_config
1239
+ if stream_tokens and (llm_config.model_endpoint_type != "openai" or "inference.memgpt.ai" in llm_config.model_endpoint):
1240
+ warnings.warn(
1241
+ "Token streaming is only supported for models with type 'openai' or `inference.memgpt.ai` in the model_endpoint: agent has endpoint type {llm_config.model_endpoint_type} and {llm_config.model_endpoint}. Setting stream_tokens to False."
1242
+ )
1243
+ stream_tokens = False
1244
+
1245
+ # Create a new interface per request
1246
+ letta_agent.interface = StreamingServerInterface(use_assistant_message)
1247
+ streaming_interface = letta_agent.interface
1248
+ if not isinstance(streaming_interface, StreamingServerInterface):
1249
+ raise ValueError(f"Agent has wrong type of interface: {type(streaming_interface)}")
1250
+
1251
+ # Enable token-streaming within the request if desired
1252
+ streaming_interface.streaming_mode = stream_tokens
1253
+ # "chatcompletion mode" does some remapping and ignores inner thoughts
1254
+ streaming_interface.streaming_chat_completion_mode = chat_completion_mode
1255
+
1256
+ # streaming_interface.allow_assistant_message = stream
1257
+ # streaming_interface.function_call_legacy_mode = stream
1258
+
1259
+ # Allow AssistantMessage is desired by client
1260
+ streaming_interface.assistant_message_tool_name = assistant_message_tool_name
1261
+ streaming_interface.assistant_message_tool_kwarg = assistant_message_tool_kwarg
1262
+
1263
+ # Related to JSON buffer reader
1264
+ streaming_interface.inner_thoughts_in_kwargs = (
1265
+ llm_config.put_inner_thoughts_in_kwargs if llm_config.put_inner_thoughts_in_kwargs is not None else False
1266
+ )
1267
+
1268
+ # Offload the synchronous message_func to a separate thread
1269
+ streaming_interface.stream_start()
1270
+ task = asyncio.create_task(
1271
+ asyncio.to_thread(
1272
+ self.send_messages,
1273
+ actor=actor,
1274
+ agent_id=agent_id,
1275
+ messages=messages,
1276
+ interface=streaming_interface,
1277
+ metadata=metadata,
1278
+ )
1279
+ )
1280
+
1281
+ if stream_steps:
1282
+ # return a stream
1283
+ return StreamingResponse(
1284
+ sse_async_generator(
1285
+ streaming_interface.get_generator(),
1286
+ usage_task=task,
1287
+ finish_message=include_final_message,
1288
+ ),
1289
+ media_type="text/event-stream",
1290
+ )
1291
+
1292
+ else:
1293
+ # buffer the stream, then return the list
1294
+ generated_stream = []
1295
+ async for message in streaming_interface.get_generator():
1296
+ assert (
1297
+ isinstance(message, LettaMessage)
1298
+ or isinstance(message, LegacyLettaMessage)
1299
+ or isinstance(message, MessageStreamStatus)
1300
+ ), type(message)
1301
+ generated_stream.append(message)
1302
+ if message == MessageStreamStatus.done:
1303
+ break
1304
+
1305
+ # Get rid of the stream status messages
1306
+ filtered_stream = [d for d in generated_stream if not isinstance(d, MessageStreamStatus)]
1307
+ usage = await task
1308
+
1309
+ # By default the stream will be messages of type LettaMessage or LettaLegacyMessage
1310
+ # If we want to convert these to Message, we can use the attached IDs
1311
+ # NOTE: we will need to de-duplicate the Messsage IDs though (since Assistant->Inner+Func_Call)
1312
+ # TODO: eventually update the interface to use `Message` and `MessageChunk` (new) inside the deque instead
1313
+ return LettaResponse(messages=filtered_stream, usage=usage)
1314
+
1315
+ except HTTPException:
1316
+ raise
1317
+ except Exception as e:
1318
+ print(e)
1319
+ import traceback
1320
+
1321
+ traceback.print_exc()
1322
+ raise HTTPException(status_code=500, detail=f"{e}")
@@ -4,11 +4,11 @@ from typing import Dict, List, Optional
4
4
  import numpy as np
5
5
  from sqlalchemy import Select, func, literal, select, union_all
6
6
 
7
- from letta.constants import BASE_MEMORY_TOOLS, BASE_TOOLS, MAX_EMBEDDING_DIM
7
+ from letta.constants import BASE_MEMORY_TOOLS, BASE_TOOLS, MAX_EMBEDDING_DIM, MULTI_AGENT_TOOLS
8
8
  from letta.embeddings import embedding_model
9
9
  from letta.log import get_logger
10
10
  from letta.orm import Agent as AgentModel
11
- from letta.orm import AgentPassage
11
+ from letta.orm import AgentPassage, AgentsTags
12
12
  from letta.orm import Block as BlockModel
13
13
  from letta.orm import Source as SourceModel
14
14
  from letta.orm import SourcePassage, SourcesAgents
@@ -22,6 +22,7 @@ from letta.schemas.block import Block as PydanticBlock
22
22
  from letta.schemas.embedding_config import EmbeddingConfig
23
23
  from letta.schemas.llm_config import LLMConfig
24
24
  from letta.schemas.message import Message as PydanticMessage
25
+ from letta.schemas.message import MessageCreate
25
26
  from letta.schemas.passage import Passage as PydanticPassage
26
27
  from letta.schemas.source import Source as PydanticSource
27
28
  from letta.schemas.tool_rule import ToolRule as PydanticToolRule
@@ -87,6 +88,8 @@ class AgentManager:
87
88
  tool_names = []
88
89
  if agent_create.include_base_tools:
89
90
  tool_names.extend(BASE_TOOLS + BASE_MEMORY_TOOLS)
91
+ if agent_create.include_multi_agent_tools:
92
+ tool_names.extend(MULTI_AGENT_TOOLS)
90
93
  if agent_create.tools:
91
94
  tool_names.extend(agent_create.tools)
92
95
  # Remove duplicates
@@ -125,13 +128,17 @@ class AgentManager:
125
128
  actor=actor,
126
129
  )
127
130
 
128
- # TODO: See if we can merge this into the above SQL create call for performance reasons
129
- # Generate a sequence of initial messages to put in the buffer
131
+ return self.append_initial_message_sequence_to_in_context_messages(actor, agent_state, agent_create.initial_message_sequence)
132
+
133
+ @enforce_types
134
+ def append_initial_message_sequence_to_in_context_messages(
135
+ self, actor: PydanticUser, agent_state: PydanticAgentState, initial_message_sequence: Optional[List[MessageCreate]] = None
136
+ ) -> PydanticAgentState:
130
137
  init_messages = initialize_message_sequence(
131
138
  agent_state=agent_state, memory_edit_timestamp=get_utc_time(), include_initial_boot_message=True
132
139
  )
133
140
 
134
- if agent_create.initial_message_sequence is not None:
141
+ if initial_message_sequence is not None:
135
142
  # We always need the system prompt up front
136
143
  system_message_obj = PydanticMessage.dict_to_message(
137
144
  agent_id=agent_state.id,
@@ -142,7 +149,7 @@ class AgentManager:
142
149
  # Don't use anything else in the pregen sequence, instead use the provided sequence
143
150
  init_messages = [system_message_obj]
144
151
  init_messages.extend(
145
- package_initial_message_sequence(agent_state.id, agent_create.initial_message_sequence, agent_state.llm_config.model, actor)
152
+ package_initial_message_sequence(agent_state.id, initial_message_sequence, agent_state.llm_config.model, actor)
146
153
  )
147
154
  else:
148
155
  init_messages = [
@@ -263,6 +270,7 @@ class AgentManager:
263
270
  match_all_tags: bool = False,
264
271
  cursor: Optional[str] = None,
265
272
  limit: Optional[int] = 50,
273
+ query_text: Optional[str] = None,
266
274
  **kwargs,
267
275
  ) -> List[PydanticAgentState]:
268
276
  """
@@ -276,6 +284,7 @@ class AgentManager:
276
284
  cursor=cursor,
277
285
  limit=limit,
278
286
  organization_id=actor.organization_id if actor else None,
287
+ query_text=query_text,
279
288
  **kwargs,
280
289
  )
281
290
 
@@ -468,6 +477,55 @@ class AgentManager:
468
477
  message_ids += [m.id for m in messages]
469
478
  return self.set_in_context_messages(agent_id=agent_id, message_ids=message_ids, actor=actor)
470
479
 
480
+ @enforce_types
481
+ def reset_messages(self, agent_id: str, actor: PydanticUser, add_default_initial_messages: bool = False) -> PydanticAgentState:
482
+ """
483
+ Removes all in-context messages for the specified agent by:
484
+ 1) Clearing the agent.messages relationship (which cascades delete-orphans).
485
+ 2) Resetting the message_ids list to empty.
486
+ 3) Committing the transaction.
487
+
488
+ This action is destructive and cannot be undone once committed.
489
+
490
+ Args:
491
+ add_default_initial_messages: If true, adds the default initial messages after resetting.
492
+ agent_id (str): The ID of the agent whose messages will be reset.
493
+ actor (PydanticUser): The user performing this action.
494
+
495
+ Returns:
496
+ PydanticAgentState: The updated agent state with no linked messages.
497
+ """
498
+ with self.session_maker() as session:
499
+ # Retrieve the existing agent (will raise NoResultFound if invalid)
500
+ agent = AgentModel.read(db_session=session, identifier=agent_id, actor=actor)
501
+
502
+ # Because of cascade="all, delete-orphan" on agent.messages, setting
503
+ # this relationship to an empty list will physically remove them from the DB.
504
+ agent.messages = []
505
+
506
+ # Also clear out the message_ids field to keep in-context memory consistent
507
+ agent.message_ids = []
508
+
509
+ # Commit the update
510
+ agent.update(db_session=session, actor=actor)
511
+
512
+ agent_state = agent.to_pydantic()
513
+
514
+ if add_default_initial_messages:
515
+ return self.append_initial_message_sequence_to_in_context_messages(actor, agent_state)
516
+ else:
517
+ # We still want to always have a system message
518
+ init_messages = initialize_message_sequence(
519
+ agent_state=agent_state, memory_edit_timestamp=get_utc_time(), include_initial_boot_message=True
520
+ )
521
+ system_message = PydanticMessage.dict_to_message(
522
+ agent_id=agent_state.id,
523
+ user_id=agent_state.created_by_id,
524
+ model=agent_state.llm_config.model,
525
+ openai_message_dict=init_messages[0],
526
+ )
527
+ return self.append_to_in_context_messages([system_message], agent_id=agent_state.id, actor=actor)
528
+
471
529
  # ======================================================================================================================
472
530
  # Source Management
473
531
  # ======================================================================================================================
@@ -945,3 +1003,40 @@ class AgentManager:
945
1003
  # Commit and refresh the agent
946
1004
  agent.update(session, actor=actor)
947
1005
  return agent.to_pydantic()
1006
+
1007
+ # ======================================================================================================================
1008
+ # Tag Management
1009
+ # ======================================================================================================================
1010
+ @enforce_types
1011
+ def list_tags(
1012
+ self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50, query_text: Optional[str] = None
1013
+ ) -> List[str]:
1014
+ """
1015
+ Get all tags a user has created, ordered alphabetically.
1016
+
1017
+ Args:
1018
+ actor: User performing the action.
1019
+ cursor: Cursor for pagination.
1020
+ limit: Maximum number of tags to return.
1021
+ query_text: Query text to filter tags by.
1022
+
1023
+ Returns:
1024
+ List[str]: List of all tags.
1025
+ """
1026
+ with self.session_maker() as session:
1027
+ query = (
1028
+ session.query(AgentsTags.tag)
1029
+ .join(AgentModel, AgentModel.id == AgentsTags.agent_id)
1030
+ .filter(AgentModel.organization_id == actor.organization_id)
1031
+ .distinct()
1032
+ )
1033
+
1034
+ if query_text:
1035
+ query = query.filter(AgentsTags.tag.ilike(f"%{query_text}%"))
1036
+
1037
+ if cursor:
1038
+ query = query.filter(AgentsTags.tag > cursor)
1039
+
1040
+ query = query.order_by(AgentsTags.tag).limit(limit)
1041
+ results = [tag[0] for tag in query.all()]
1042
+ return results
@@ -1,9 +1,23 @@
1
- from typing import List, Optional
1
+ from typing import List, Literal, Optional, Union
2
2
 
3
+ from sqlalchemy import select
4
+ from sqlalchemy.orm import Session
5
+
6
+ from letta.orm.enums import JobType
7
+ from letta.orm.errors import NoResultFound
3
8
  from letta.orm.job import Job as JobModel
4
- from letta.schemas.enums import JobStatus
9
+ from letta.orm.job_messages import JobMessage
10
+ from letta.orm.job_usage_statistics import JobUsageStatistics
11
+ from letta.orm.message import Message as MessageModel
12
+ from letta.orm.sqlalchemy_base import AccessType
13
+ from letta.schemas.enums import JobStatus, MessageRole
5
14
  from letta.schemas.job import Job as PydanticJob
6
15
  from letta.schemas.job import JobUpdate
16
+ from letta.schemas.letta_message import LettaMessage
17
+ from letta.schemas.letta_request import LettaRequestConfig
18
+ from letta.schemas.message import Message as PydanticMessage
19
+ from letta.schemas.run import Run as PydanticRun
20
+ from letta.schemas.usage import LettaUsageStatistics
7
21
  from letta.schemas.user import User as PydanticUser
8
22
  from letta.utils import enforce_types, get_utc_time
9
23
 
@@ -18,7 +32,7 @@ class JobManager:
18
32
  self.session_maker = db_context
19
33
 
20
34
  @enforce_types
21
- def create_job(self, pydantic_job: PydanticJob, actor: PydanticUser) -> PydanticJob:
35
+ def create_job(self, pydantic_job: Union[PydanticJob, PydanticRun], actor: PydanticUser) -> Union[PydanticJob, PydanticRun]:
22
36
  """Create a new job based on the JobCreate schema."""
23
37
  with self.session_maker() as session:
24
38
  # Associate the job with the user
@@ -33,7 +47,7 @@ class JobManager:
33
47
  """Update a job by its ID with the given JobUpdate object."""
34
48
  with self.session_maker() as session:
35
49
  # Fetch the job by ID
36
- job = JobModel.read(db_session=session, identifier=job_id) # TODO: Add this later , actor=actor)
50
+ job = self._verify_job_access(session=session, job_id=job_id, actor=actor, access=["write"])
37
51
 
38
52
  # Update job attributes with only the fields that were explicitly set
39
53
  update_data = job_update.model_dump(exclude_unset=True, exclude_none=True)
@@ -53,16 +67,21 @@ class JobManager:
53
67
  """Fetch a job by its ID."""
54
68
  with self.session_maker() as session:
55
69
  # Retrieve job by ID using the Job model's read method
56
- job = JobModel.read(db_session=session, identifier=job_id) # TODO: Add this later , actor=actor)
70
+ job = JobModel.read(db_session=session, identifier=job_id, actor=actor, access_type=AccessType.USER)
57
71
  return job.to_pydantic()
58
72
 
59
73
  @enforce_types
60
74
  def list_jobs(
61
- self, actor: PydanticUser, cursor: Optional[str] = None, limit: Optional[int] = 50, statuses: Optional[List[JobStatus]] = None
75
+ self,
76
+ actor: PydanticUser,
77
+ cursor: Optional[str] = None,
78
+ limit: Optional[int] = 50,
79
+ statuses: Optional[List[JobStatus]] = None,
80
+ job_type: JobType = JobType.JOB,
62
81
  ) -> List[PydanticJob]:
63
82
  """List all jobs with optional pagination and status filter."""
64
83
  with self.session_maker() as session:
65
- filter_kwargs = {"user_id": actor.id}
84
+ filter_kwargs = {"user_id": actor.id, "job_type": job_type}
66
85
 
67
86
  # Add status filter if provided
68
87
  if statuses:
@@ -80,6 +99,252 @@ class JobManager:
80
99
  def delete_job_by_id(self, job_id: str, actor: PydanticUser) -> PydanticJob:
81
100
  """Delete a job by its ID."""
82
101
  with self.session_maker() as session:
83
- job = JobModel.read(db_session=session, identifier=job_id) # TODO: Add this later , actor=actor)
84
- job.hard_delete(db_session=session) # TODO: Add this later , actor=actor)
102
+ job = self._verify_job_access(session=session, job_id=job_id, actor=actor)
103
+ job.hard_delete(db_session=session, actor=actor)
85
104
  return job.to_pydantic()
105
+
106
+ @enforce_types
107
+ def get_job_messages(
108
+ self,
109
+ job_id: str,
110
+ actor: PydanticUser,
111
+ cursor: Optional[str] = None,
112
+ limit: Optional[int] = 100,
113
+ role: Optional[MessageRole] = None,
114
+ ascending: bool = True,
115
+ ) -> List[PydanticMessage]:
116
+ """
117
+ Get all messages associated with a job.
118
+
119
+ Args:
120
+ job_id: The ID of the job to get messages for
121
+ actor: The user making the request
122
+ cursor: Cursor for pagination
123
+ limit: Maximum number of messages to return
124
+ role: Optional filter for message role
125
+ ascending: Optional flag to sort in ascending order
126
+
127
+ Returns:
128
+ List of messages associated with the job
129
+
130
+ Raises:
131
+ NoResultFound: If the job does not exist or user does not have access
132
+ """
133
+ with self.session_maker() as session:
134
+ # Build filters
135
+ filters = {}
136
+ if role is not None:
137
+ filters["role"] = role
138
+
139
+ # Get messages
140
+ messages = MessageModel.list(
141
+ db_session=session,
142
+ cursor=cursor,
143
+ ascending=ascending,
144
+ limit=limit,
145
+ actor=actor,
146
+ join_model=JobMessage,
147
+ join_conditions=[MessageModel.id == JobMessage.message_id, JobMessage.job_id == job_id],
148
+ **filters,
149
+ )
150
+
151
+ return [message.to_pydantic() for message in messages]
152
+
153
+ @enforce_types
154
+ def add_message_to_job(self, job_id: str, message_id: str, actor: PydanticUser) -> None:
155
+ """
156
+ Associate a message with a job by creating a JobMessage record.
157
+ Each message can only be associated with one job.
158
+
159
+ Args:
160
+ job_id: The ID of the job
161
+ message_id: The ID of the message to associate
162
+ actor: The user making the request
163
+
164
+ Raises:
165
+ NoResultFound: If the job does not exist or user does not have access
166
+ """
167
+ with self.session_maker() as session:
168
+ # First verify job exists and user has access
169
+ self._verify_job_access(session, job_id, actor, access=["write"])
170
+
171
+ # Create new JobMessage association
172
+ job_message = JobMessage(job_id=job_id, message_id=message_id)
173
+ session.add(job_message)
174
+ session.commit()
175
+
176
+ @enforce_types
177
+ def get_job_usage(self, job_id: str, actor: PydanticUser) -> LettaUsageStatistics:
178
+ """
179
+ Get usage statistics for a job.
180
+
181
+ Args:
182
+ job_id: The ID of the job
183
+ actor: The user making the request
184
+
185
+ Returns:
186
+ Usage statistics for the job
187
+
188
+ Raises:
189
+ NoResultFound: If the job does not exist or user does not have access
190
+ """
191
+ with self.session_maker() as session:
192
+ # First verify job exists and user has access
193
+ self._verify_job_access(session, job_id, actor)
194
+
195
+ # Get the latest usage statistics for the job
196
+ latest_stats = (
197
+ session.query(JobUsageStatistics)
198
+ .filter(JobUsageStatistics.job_id == job_id)
199
+ .order_by(JobUsageStatistics.created_at.desc())
200
+ .first()
201
+ )
202
+
203
+ if not latest_stats:
204
+ return LettaUsageStatistics(
205
+ completion_tokens=0,
206
+ prompt_tokens=0,
207
+ total_tokens=0,
208
+ step_count=0,
209
+ )
210
+
211
+ return LettaUsageStatistics(
212
+ completion_tokens=latest_stats.completion_tokens,
213
+ prompt_tokens=latest_stats.prompt_tokens,
214
+ total_tokens=latest_stats.total_tokens,
215
+ step_count=latest_stats.step_count,
216
+ )
217
+
218
+ @enforce_types
219
+ def add_job_usage(
220
+ self,
221
+ job_id: str,
222
+ usage: LettaUsageStatistics,
223
+ step_id: Optional[str] = None,
224
+ actor: PydanticUser = None,
225
+ ) -> None:
226
+ """
227
+ Add usage statistics for a job.
228
+
229
+ Args:
230
+ job_id: The ID of the job
231
+ usage: Usage statistics for the job
232
+ step_id: Optional ID of the specific step within the job
233
+ actor: The user making the request
234
+
235
+ Raises:
236
+ NoResultFound: If the job does not exist or user does not have access
237
+ """
238
+ with self.session_maker() as session:
239
+ # First verify job exists and user has access
240
+ self._verify_job_access(session, job_id, actor, access=["write"])
241
+
242
+ # Create new usage statistics entry
243
+ usage_stats = JobUsageStatistics(
244
+ job_id=job_id,
245
+ completion_tokens=usage.completion_tokens,
246
+ prompt_tokens=usage.prompt_tokens,
247
+ total_tokens=usage.total_tokens,
248
+ step_count=usage.step_count,
249
+ step_id=step_id,
250
+ )
251
+ if actor:
252
+ usage_stats._set_created_and_updated_by_fields(actor.id)
253
+
254
+ session.add(usage_stats)
255
+ session.commit()
256
+
257
+ @enforce_types
258
+ def get_run_messages_cursor(
259
+ self,
260
+ run_id: str,
261
+ actor: PydanticUser,
262
+ cursor: Optional[str] = None,
263
+ limit: Optional[int] = 100,
264
+ role: Optional[MessageRole] = None,
265
+ ascending: bool = True,
266
+ ) -> List[LettaMessage]:
267
+ """
268
+ Get messages associated with a job using cursor-based pagination.
269
+ This is a wrapper around get_job_messages that provides cursor-based pagination.
270
+
271
+ Args:
272
+ job_id: The ID of the job to get messages for
273
+ actor: The user making the request
274
+ cursor: Message ID to get messages after or before
275
+ limit: Maximum number of messages to return
276
+ ascending: Whether to return messages in ascending order
277
+ role: Optional role filter
278
+
279
+ Returns:
280
+ List of LettaMessages associated with the job
281
+
282
+ Raises:
283
+ NoResultFound: If the job does not exist or user does not have access
284
+ """
285
+ messages = self.get_job_messages(
286
+ job_id=run_id,
287
+ actor=actor,
288
+ cursor=cursor,
289
+ limit=limit,
290
+ role=role,
291
+ ascending=ascending,
292
+ )
293
+
294
+ request_config = self._get_run_request_config(run_id)
295
+
296
+ # Convert messages to LettaMessages
297
+ messages = [
298
+ msg
299
+ for m in messages
300
+ for msg in m.to_letta_message(
301
+ assistant_message=request_config["use_assistant_message"],
302
+ assistant_message_tool_name=request_config["assistant_message_tool_name"],
303
+ assistant_message_tool_kwarg=request_config["assistant_message_tool_kwarg"],
304
+ )
305
+ ]
306
+
307
+ return messages
308
+
309
+ def _verify_job_access(
310
+ self,
311
+ session: Session,
312
+ job_id: str,
313
+ actor: PydanticUser,
314
+ access: List[Literal["read", "write", "delete"]] = ["read"],
315
+ ) -> JobModel:
316
+ """
317
+ Verify that a job exists and the user has the required access.
318
+
319
+ Args:
320
+ session: The database session
321
+ job_id: The ID of the job to verify
322
+ actor: The user making the request
323
+
324
+ Returns:
325
+ The job if it exists and the user has access
326
+
327
+ Raises:
328
+ NoResultFound: If the job does not exist or user does not have access
329
+ """
330
+ job_query = select(JobModel).where(JobModel.id == job_id)
331
+ job_query = JobModel.apply_access_predicate(job_query, actor, access, AccessType.USER)
332
+ job = session.execute(job_query).scalar_one_or_none()
333
+ if not job:
334
+ raise NoResultFound(f"Job with id {job_id} does not exist or user does not have access")
335
+ return job
336
+
337
+ def _get_run_request_config(self, run_id: str) -> LettaRequestConfig:
338
+ """
339
+ Get the request config for a job.
340
+
341
+ Args:
342
+ job_id: The ID of the job to get messages for
343
+
344
+ Returns:
345
+ The request config for the job
346
+ """
347
+ with self.session_maker() as session:
348
+ job = session.query(JobModel).filter(JobModel.id == run_id).first()
349
+ request_config = job.request_config or LettaRequestConfig()
350
+ return request_config