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.
- letta/__init__.py +1 -0
- letta/agent.py +24 -0
- letta/client/client.py +274 -11
- letta/constants.py +5 -0
- letta/functions/function_sets/multi_agent.py +96 -0
- letta/functions/helpers.py +105 -1
- letta/functions/schema_generator.py +8 -0
- letta/llm_api/openai.py +18 -2
- letta/local_llm/utils.py +4 -0
- letta/orm/__init__.py +1 -0
- letta/orm/enums.py +6 -0
- letta/orm/job.py +24 -2
- letta/orm/job_messages.py +33 -0
- letta/orm/job_usage_statistics.py +30 -0
- letta/orm/message.py +10 -0
- letta/orm/sqlalchemy_base.py +28 -4
- letta/orm/tool.py +0 -3
- letta/schemas/agent.py +10 -4
- letta/schemas/job.py +2 -0
- letta/schemas/letta_base.py +6 -1
- letta/schemas/letta_request.py +6 -4
- letta/schemas/llm_config.py +1 -1
- letta/schemas/message.py +2 -4
- letta/schemas/providers.py +1 -1
- letta/schemas/run.py +61 -0
- letta/schemas/tool.py +9 -17
- letta/server/rest_api/interface.py +3 -0
- letta/server/rest_api/routers/openai/chat_completions/chat_completions.py +6 -12
- letta/server/rest_api/routers/v1/__init__.py +4 -0
- letta/server/rest_api/routers/v1/agents.py +47 -151
- letta/server/rest_api/routers/v1/runs.py +137 -0
- letta/server/rest_api/routers/v1/tags.py +27 -0
- letta/server/rest_api/utils.py +5 -3
- letta/server/server.py +139 -2
- letta/services/agent_manager.py +101 -6
- letta/services/job_manager.py +274 -9
- letta/services/tool_execution_sandbox.py +1 -1
- letta/services/tool_manager.py +30 -25
- letta/utils.py +3 -4
- {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/METADATA +4 -3
- {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/RECORD +44 -38
- {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/LICENSE +0 -0
- {letta_nightly-0.6.9.dev20250116104035.dist-info → letta_nightly-0.6.9.dev20250117104025.dist-info}/WHEEL +0 -0
- {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}")
|
letta/services/agent_manager.py
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
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
|
|
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,
|
|
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
|
letta/services/job_manager.py
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
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,
|
|
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 =
|
|
84
|
-
job.hard_delete(db_session=session
|
|
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
|