letta-nightly 0.11.7.dev20251007104119__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.
- letta/adapters/letta_llm_adapter.py +1 -0
- letta/adapters/letta_llm_request_adapter.py +0 -1
- letta/adapters/letta_llm_stream_adapter.py +7 -2
- letta/adapters/simple_llm_request_adapter.py +88 -0
- letta/adapters/simple_llm_stream_adapter.py +192 -0
- letta/agents/agent_loop.py +6 -0
- letta/agents/ephemeral_summary_agent.py +2 -1
- letta/agents/helpers.py +142 -6
- letta/agents/letta_agent.py +13 -33
- letta/agents/letta_agent_batch.py +2 -4
- letta/agents/letta_agent_v2.py +87 -77
- letta/agents/letta_agent_v3.py +899 -0
- letta/agents/voice_agent.py +2 -6
- letta/constants.py +8 -4
- letta/errors.py +40 -0
- letta/functions/function_sets/base.py +84 -4
- letta/functions/function_sets/multi_agent.py +0 -3
- letta/functions/schema_generator.py +113 -71
- letta/groups/dynamic_multi_agent.py +3 -2
- letta/groups/helpers.py +1 -2
- letta/groups/round_robin_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent.py +3 -2
- letta/groups/sleeptime_multi_agent_v2.py +1 -1
- letta/groups/sleeptime_multi_agent_v3.py +17 -17
- letta/groups/supervisor_multi_agent.py +84 -80
- letta/helpers/converters.py +3 -0
- letta/helpers/message_helper.py +4 -0
- letta/helpers/tool_rule_solver.py +92 -5
- letta/interfaces/anthropic_streaming_interface.py +409 -0
- letta/interfaces/gemini_streaming_interface.py +296 -0
- letta/interfaces/openai_streaming_interface.py +752 -1
- letta/llm_api/anthropic_client.py +126 -16
- letta/llm_api/bedrock_client.py +4 -2
- letta/llm_api/deepseek_client.py +4 -1
- letta/llm_api/google_vertex_client.py +123 -42
- letta/llm_api/groq_client.py +4 -1
- letta/llm_api/llm_api_tools.py +11 -4
- letta/llm_api/llm_client_base.py +6 -2
- letta/llm_api/openai.py +32 -2
- letta/llm_api/openai_client.py +423 -18
- letta/llm_api/xai_client.py +4 -1
- letta/main.py +9 -5
- letta/memory.py +1 -0
- letta/orm/__init__.py +1 -1
- letta/orm/agent.py +10 -0
- letta/orm/block.py +7 -16
- letta/orm/blocks_agents.py +8 -2
- letta/orm/files_agents.py +2 -0
- letta/orm/job.py +7 -5
- letta/orm/mcp_oauth.py +1 -0
- letta/orm/message.py +21 -6
- letta/orm/organization.py +2 -0
- letta/orm/provider.py +6 -2
- letta/orm/run.py +71 -0
- letta/orm/sandbox_config.py +7 -1
- letta/orm/sqlalchemy_base.py +0 -306
- letta/orm/step.py +6 -5
- letta/orm/step_metrics.py +5 -5
- letta/otel/tracing.py +28 -3
- letta/plugins/defaults.py +4 -4
- letta/prompts/system_prompts/__init__.py +2 -0
- letta/prompts/system_prompts/letta_v1.py +25 -0
- letta/schemas/agent.py +3 -2
- letta/schemas/agent_file.py +9 -3
- letta/schemas/block.py +23 -10
- letta/schemas/enums.py +21 -2
- letta/schemas/job.py +17 -4
- letta/schemas/letta_message_content.py +71 -2
- letta/schemas/letta_stop_reason.py +5 -5
- letta/schemas/llm_config.py +53 -3
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +504 -117
- letta/schemas/openai/responses_request.py +64 -0
- letta/schemas/providers/__init__.py +2 -0
- letta/schemas/providers/anthropic.py +16 -0
- letta/schemas/providers/ollama.py +115 -33
- letta/schemas/providers/openrouter.py +52 -0
- letta/schemas/providers/vllm.py +2 -1
- letta/schemas/run.py +48 -42
- letta/schemas/step.py +2 -2
- letta/schemas/step_metrics.py +1 -1
- letta/schemas/tool.py +15 -107
- letta/schemas/tool_rule.py +88 -5
- letta/serialize_schemas/marshmallow_agent.py +1 -0
- letta/server/db.py +86 -408
- letta/server/rest_api/app.py +61 -10
- letta/server/rest_api/dependencies.py +14 -0
- letta/server/rest_api/redis_stream_manager.py +19 -8
- letta/server/rest_api/routers/v1/agents.py +364 -292
- letta/server/rest_api/routers/v1/blocks.py +14 -20
- letta/server/rest_api/routers/v1/identities.py +45 -110
- letta/server/rest_api/routers/v1/internal_templates.py +21 -0
- letta/server/rest_api/routers/v1/jobs.py +23 -6
- letta/server/rest_api/routers/v1/messages.py +1 -1
- letta/server/rest_api/routers/v1/runs.py +126 -85
- letta/server/rest_api/routers/v1/sandbox_configs.py +10 -19
- letta/server/rest_api/routers/v1/tools.py +281 -594
- letta/server/rest_api/routers/v1/voice.py +1 -1
- letta/server/rest_api/streaming_response.py +29 -29
- letta/server/rest_api/utils.py +122 -64
- letta/server/server.py +160 -887
- letta/services/agent_manager.py +236 -919
- letta/services/agent_serialization_manager.py +16 -0
- letta/services/archive_manager.py +0 -100
- letta/services/block_manager.py +211 -168
- letta/services/file_manager.py +1 -1
- letta/services/files_agents_manager.py +24 -33
- letta/services/group_manager.py +0 -142
- letta/services/helpers/agent_manager_helper.py +7 -2
- letta/services/helpers/run_manager_helper.py +85 -0
- letta/services/job_manager.py +96 -411
- letta/services/lettuce/__init__.py +6 -0
- letta/services/lettuce/lettuce_client_base.py +86 -0
- letta/services/mcp_manager.py +38 -6
- letta/services/message_manager.py +165 -362
- letta/services/organization_manager.py +0 -36
- letta/services/passage_manager.py +0 -345
- letta/services/provider_manager.py +0 -80
- letta/services/run_manager.py +301 -0
- letta/services/sandbox_config_manager.py +0 -234
- letta/services/step_manager.py +62 -39
- letta/services/summarizer/summarizer.py +9 -7
- letta/services/telemetry_manager.py +0 -16
- letta/services/tool_executor/builtin_tool_executor.py +35 -0
- letta/services/tool_executor/core_tool_executor.py +397 -2
- letta/services/tool_executor/files_tool_executor.py +3 -3
- letta/services/tool_executor/multi_agent_tool_executor.py +30 -15
- letta/services/tool_executor/tool_execution_manager.py +6 -8
- letta/services/tool_executor/tool_executor_base.py +3 -3
- letta/services/tool_manager.py +85 -339
- letta/services/tool_sandbox/base.py +24 -13
- letta/services/tool_sandbox/e2b_sandbox.py +16 -1
- letta/services/tool_schema_generator.py +123 -0
- letta/services/user_manager.py +0 -99
- letta/settings.py +20 -4
- {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/METADATA +3 -5
- {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/RECORD +140 -132
- letta/agents/temporal/activities/__init__.py +0 -4
- letta/agents/temporal/activities/example_activity.py +0 -7
- letta/agents/temporal/activities/prepare_messages.py +0 -10
- letta/agents/temporal/temporal_agent_workflow.py +0 -56
- letta/agents/temporal/types.py +0 -25
- {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/WHEEL +0 -0
- {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.11.7.dev20251007104119.dist-info → letta_nightly-0.11.7.dev20251008104128.dist-info}/licenses/LICENSE +0 -0
letta/services/group_manager.py
CHANGED
@@ -60,13 +60,6 @@ class GroupManager:
|
|
60
60
|
groups = result.scalars().all()
|
61
61
|
return [group.to_pydantic() for group in groups]
|
62
62
|
|
63
|
-
@enforce_types
|
64
|
-
@trace_method
|
65
|
-
def retrieve_group(self, group_id: str, actor: PydanticUser) -> PydanticGroup:
|
66
|
-
with db_registry.session() as session:
|
67
|
-
group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
|
68
|
-
return group.to_pydantic()
|
69
|
-
|
70
63
|
@enforce_types
|
71
64
|
@trace_method
|
72
65
|
async def retrieve_group_async(self, group_id: str, actor: PydanticUser) -> PydanticGroup:
|
@@ -74,57 +67,6 @@ class GroupManager:
|
|
74
67
|
group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor)
|
75
68
|
return group.to_pydantic()
|
76
69
|
|
77
|
-
@enforce_types
|
78
|
-
@trace_method
|
79
|
-
def create_group(self, group: Union[GroupCreate, InternalTemplateGroupCreate], actor: PydanticUser) -> PydanticGroup:
|
80
|
-
with db_registry.session() as session:
|
81
|
-
new_group = GroupModel()
|
82
|
-
new_group.organization_id = actor.organization_id
|
83
|
-
new_group.description = group.description
|
84
|
-
|
85
|
-
match group.manager_config.manager_type:
|
86
|
-
case ManagerType.round_robin:
|
87
|
-
new_group.manager_type = ManagerType.round_robin
|
88
|
-
new_group.max_turns = group.manager_config.max_turns
|
89
|
-
case ManagerType.dynamic:
|
90
|
-
new_group.manager_type = ManagerType.dynamic
|
91
|
-
new_group.manager_agent_id = group.manager_config.manager_agent_id
|
92
|
-
new_group.max_turns = group.manager_config.max_turns
|
93
|
-
new_group.termination_token = group.manager_config.termination_token
|
94
|
-
case ManagerType.supervisor:
|
95
|
-
new_group.manager_type = ManagerType.supervisor
|
96
|
-
new_group.manager_agent_id = group.manager_config.manager_agent_id
|
97
|
-
case ManagerType.sleeptime:
|
98
|
-
new_group.manager_type = ManagerType.sleeptime
|
99
|
-
new_group.manager_agent_id = group.manager_config.manager_agent_id
|
100
|
-
new_group.sleeptime_agent_frequency = group.manager_config.sleeptime_agent_frequency
|
101
|
-
if new_group.sleeptime_agent_frequency:
|
102
|
-
new_group.turns_counter = -1
|
103
|
-
case ManagerType.voice_sleeptime:
|
104
|
-
new_group.manager_type = ManagerType.voice_sleeptime
|
105
|
-
new_group.manager_agent_id = group.manager_config.manager_agent_id
|
106
|
-
max_message_buffer_length = group.manager_config.max_message_buffer_length
|
107
|
-
min_message_buffer_length = group.manager_config.min_message_buffer_length
|
108
|
-
# Safety check for buffer length range
|
109
|
-
self.ensure_buffer_length_range_valid(max_value=max_message_buffer_length, min_value=min_message_buffer_length)
|
110
|
-
new_group.max_message_buffer_length = max_message_buffer_length
|
111
|
-
new_group.min_message_buffer_length = min_message_buffer_length
|
112
|
-
case _:
|
113
|
-
raise ValueError(f"Unsupported manager type: {group.manager_config.manager_type}")
|
114
|
-
|
115
|
-
if isinstance(group, InternalTemplateGroupCreate):
|
116
|
-
new_group.base_template_id = group.base_template_id
|
117
|
-
new_group.template_id = group.template_id
|
118
|
-
new_group.deployment_id = group.deployment_id
|
119
|
-
|
120
|
-
self._process_agent_relationship(session=session, group=new_group, agent_ids=group.agent_ids, allow_partial=False)
|
121
|
-
|
122
|
-
if group.shared_block_ids:
|
123
|
-
self._process_shared_block_relationship(session=session, group=new_group, block_ids=group.shared_block_ids)
|
124
|
-
|
125
|
-
new_group.create(session, actor=actor)
|
126
|
-
return new_group.to_pydantic()
|
127
|
-
|
128
70
|
@enforce_types
|
129
71
|
async def create_group_async(self, group: Union[GroupCreate, InternalTemplateGroupCreate], actor: PydanticUser) -> PydanticGroup:
|
130
72
|
async with db_registry.async_session() as session:
|
@@ -238,14 +180,6 @@ class GroupManager:
|
|
238
180
|
await group.update_async(session, actor=actor)
|
239
181
|
return group.to_pydantic()
|
240
182
|
|
241
|
-
@enforce_types
|
242
|
-
@trace_method
|
243
|
-
def delete_group(self, group_id: str, actor: PydanticUser) -> None:
|
244
|
-
with db_registry.session() as session:
|
245
|
-
# Retrieve the agent
|
246
|
-
group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
|
247
|
-
group.hard_delete(session)
|
248
|
-
|
249
183
|
@enforce_types
|
250
184
|
@trace_method
|
251
185
|
async def delete_group_async(self, group_id: str, actor: PydanticUser) -> None:
|
@@ -253,43 +187,6 @@ class GroupManager:
|
|
253
187
|
group = await GroupModel.read_async(db_session=session, identifier=group_id, actor=actor)
|
254
188
|
await group.hard_delete_async(session)
|
255
189
|
|
256
|
-
@enforce_types
|
257
|
-
@trace_method
|
258
|
-
def list_group_messages(
|
259
|
-
self,
|
260
|
-
actor: PydanticUser,
|
261
|
-
group_id: Optional[str] = None,
|
262
|
-
before: Optional[str] = None,
|
263
|
-
after: Optional[str] = None,
|
264
|
-
limit: Optional[int] = 50,
|
265
|
-
use_assistant_message: bool = True,
|
266
|
-
assistant_message_tool_name: str = "send_message",
|
267
|
-
assistant_message_tool_kwarg: str = "message",
|
268
|
-
) -> list[LettaMessage]:
|
269
|
-
with db_registry.session() as session:
|
270
|
-
filters = {
|
271
|
-
"organization_id": actor.organization_id,
|
272
|
-
"group_id": group_id,
|
273
|
-
}
|
274
|
-
messages = MessageModel.list(
|
275
|
-
db_session=session,
|
276
|
-
before=before,
|
277
|
-
after=after,
|
278
|
-
limit=limit,
|
279
|
-
**filters,
|
280
|
-
)
|
281
|
-
|
282
|
-
messages = PydanticMessage.to_letta_messages_from_list(
|
283
|
-
messages=[msg.to_pydantic() for msg in messages],
|
284
|
-
use_assistant_message=use_assistant_message,
|
285
|
-
assistant_message_tool_name=assistant_message_tool_name,
|
286
|
-
assistant_message_tool_kwarg=assistant_message_tool_kwarg,
|
287
|
-
)
|
288
|
-
|
289
|
-
# TODO: filter messages to return a clean conversation history
|
290
|
-
|
291
|
-
return messages
|
292
|
-
|
293
190
|
@enforce_types
|
294
191
|
@trace_method
|
295
192
|
async def list_group_messages_async(
|
@@ -327,20 +224,6 @@ class GroupManager:
|
|
327
224
|
|
328
225
|
return messages
|
329
226
|
|
330
|
-
@enforce_types
|
331
|
-
@trace_method
|
332
|
-
def reset_messages(self, group_id: str, actor: PydanticUser) -> None:
|
333
|
-
with db_registry.session() as session:
|
334
|
-
# Ensure group is loadable by user
|
335
|
-
group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
|
336
|
-
|
337
|
-
# Delete all messages in the group
|
338
|
-
session.query(MessageModel).filter(
|
339
|
-
MessageModel.organization_id == actor.organization_id, MessageModel.group_id == group_id
|
340
|
-
).delete(synchronize_session=False)
|
341
|
-
|
342
|
-
session.commit()
|
343
|
-
|
344
227
|
@enforce_types
|
345
228
|
@trace_method
|
346
229
|
async def reset_messages_async(self, group_id: str, actor: PydanticUser) -> None:
|
@@ -356,18 +239,6 @@ class GroupManager:
|
|
356
239
|
|
357
240
|
await session.commit()
|
358
241
|
|
359
|
-
@enforce_types
|
360
|
-
@trace_method
|
361
|
-
def bump_turns_counter(self, group_id: str, actor: PydanticUser) -> int:
|
362
|
-
with db_registry.session() as session:
|
363
|
-
# Ensure group is loadable by user
|
364
|
-
group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
|
365
|
-
|
366
|
-
# Update turns counter
|
367
|
-
group.turns_counter = (group.turns_counter + 1) % group.sleeptime_agent_frequency
|
368
|
-
group.update(session, actor=actor)
|
369
|
-
return group.turns_counter
|
370
|
-
|
371
242
|
@enforce_types
|
372
243
|
@trace_method
|
373
244
|
async def bump_turns_counter_async(self, group_id: str, actor: PydanticUser) -> int:
|
@@ -380,19 +251,6 @@ class GroupManager:
|
|
380
251
|
await group.update_async(session, actor=actor)
|
381
252
|
return group.turns_counter
|
382
253
|
|
383
|
-
@enforce_types
|
384
|
-
def get_last_processed_message_id_and_update(self, group_id: str, last_processed_message_id: str, actor: PydanticUser) -> str:
|
385
|
-
with db_registry.session() as session:
|
386
|
-
# Ensure group is loadable by user
|
387
|
-
group = GroupModel.read(db_session=session, identifier=group_id, actor=actor)
|
388
|
-
|
389
|
-
# Update last processed message id
|
390
|
-
prev_last_processed_message_id = group.last_processed_message_id
|
391
|
-
group.last_processed_message_id = last_processed_message_id
|
392
|
-
group.update(session, actor=actor)
|
393
|
-
|
394
|
-
return prev_last_processed_message_id
|
395
|
-
|
396
254
|
@enforce_types
|
397
255
|
@trace_method
|
398
256
|
async def get_last_processed_message_id_and_update_async(
|
@@ -19,6 +19,7 @@ from letta.constants import (
|
|
19
19
|
MULTI_AGENT_TOOLS,
|
20
20
|
STRUCTURED_OUTPUT_MODELS,
|
21
21
|
)
|
22
|
+
from letta.errors import LettaAgentNotFoundError
|
22
23
|
from letta.helpers import ToolRulesSolver
|
23
24
|
from letta.helpers.datetime_helpers import get_local_time
|
24
25
|
from letta.llm_api.llm_client import LLMClient
|
@@ -206,6 +207,10 @@ def derive_system_message(agent_type: AgentType, enable_sleeptime: Optional[bool
|
|
206
207
|
elif agent_type == AgentType.react_agent:
|
207
208
|
system = gpt_system.get_system_text("react")
|
208
209
|
|
210
|
+
# Letta v1
|
211
|
+
elif agent_type == AgentType.letta_v1_agent:
|
212
|
+
system = gpt_system.get_system_text("letta_v1")
|
213
|
+
|
209
214
|
# Workflow
|
210
215
|
elif agent_type == AgentType.workflow_agent:
|
211
216
|
system = gpt_system.get_system_text("workflow")
|
@@ -419,7 +424,7 @@ async def initialize_message_sequence_async(
|
|
419
424
|
# Some LMStudio models (e.g. ministral) require the tool call ID to be 9 alphanumeric characters
|
420
425
|
tool_call_id = uuid_str[:9] if llm_config.provider_name == "lmstudio_openai" else uuid_str
|
421
426
|
|
422
|
-
if agent_state.agent_type == AgentType.sleeptime_agent:
|
427
|
+
if agent_state.agent_type == AgentType.sleeptime_agent or agent_state.agent_type == AgentType.letta_v1_agent:
|
423
428
|
initial_boot_messages = []
|
424
429
|
elif llm_config.model is not None and "gpt-3.5" in llm_config.model:
|
425
430
|
initial_boot_messages = get_initial_boot_messages("startup_with_send_message_gpt35", agent_state.timezone, tool_call_id)
|
@@ -1238,4 +1243,4 @@ async def validate_agent_exists_async(session, agent_id: str, actor: User) -> No
|
|
1238
1243
|
result = await session.execute(agent_exists_query)
|
1239
1244
|
|
1240
1245
|
if not result.scalar():
|
1241
|
-
raise
|
1246
|
+
raise LettaAgentNotFoundError(f"Agent with ID {agent_id} not found")
|
@@ -0,0 +1,85 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
from sqlalchemy import asc, desc, nulls_last, select
|
5
|
+
from letta.settings import DatabaseChoice, settings
|
6
|
+
|
7
|
+
from letta.orm.run import Run as RunModel
|
8
|
+
from letta.settings import DatabaseChoice, settings
|
9
|
+
from sqlalchemy import asc, desc
|
10
|
+
from typing import Optional
|
11
|
+
|
12
|
+
from letta.services.helpers.agent_manager_helper import _cursor_filter
|
13
|
+
|
14
|
+
|
15
|
+
async def _apply_pagination_async(
|
16
|
+
query,
|
17
|
+
before: Optional[str],
|
18
|
+
after: Optional[str],
|
19
|
+
session,
|
20
|
+
ascending: bool = True,
|
21
|
+
sort_by: str = "created_at",
|
22
|
+
) -> any:
|
23
|
+
# Determine the sort column
|
24
|
+
if sort_by == "last_run_completion":
|
25
|
+
sort_column = RunModel.last_run_completion
|
26
|
+
sort_nulls_last = True # TODO: handle this as a query param eventually
|
27
|
+
else:
|
28
|
+
sort_column = RunModel.created_at
|
29
|
+
sort_nulls_last = False
|
30
|
+
|
31
|
+
if after:
|
32
|
+
result = (
|
33
|
+
await session.execute(
|
34
|
+
select(sort_column, RunModel.id).where(RunModel.id == after)
|
35
|
+
)
|
36
|
+
).first()
|
37
|
+
if result:
|
38
|
+
after_sort_value, after_id = result
|
39
|
+
# SQLite does not support as granular timestamping, so we need to round the timestamp
|
40
|
+
if settings.database_engine is DatabaseChoice.SQLITE and isinstance(
|
41
|
+
after_sort_value, datetime
|
42
|
+
):
|
43
|
+
after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
|
44
|
+
query = query.where(
|
45
|
+
_cursor_filter(
|
46
|
+
sort_column,
|
47
|
+
RunModel.id,
|
48
|
+
after_sort_value,
|
49
|
+
after_id,
|
50
|
+
forward=ascending,
|
51
|
+
nulls_last=sort_nulls_last,
|
52
|
+
)
|
53
|
+
)
|
54
|
+
|
55
|
+
if before:
|
56
|
+
result = (
|
57
|
+
await session.execute(
|
58
|
+
select(sort_column, RunModel.id).where(RunModel.id == before)
|
59
|
+
)
|
60
|
+
).first()
|
61
|
+
if result:
|
62
|
+
before_sort_value, before_id = result
|
63
|
+
# SQLite does not support as granular timestamping, so we need to round the timestamp
|
64
|
+
if settings.database_engine is DatabaseChoice.SQLITE and isinstance(
|
65
|
+
before_sort_value, datetime
|
66
|
+
):
|
67
|
+
before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
|
68
|
+
query = query.where(
|
69
|
+
_cursor_filter(
|
70
|
+
sort_column,
|
71
|
+
RunModel.id,
|
72
|
+
before_sort_value,
|
73
|
+
before_id,
|
74
|
+
forward=not ascending,
|
75
|
+
nulls_last=sort_nulls_last,
|
76
|
+
)
|
77
|
+
)
|
78
|
+
|
79
|
+
# Apply ordering
|
80
|
+
order_fn = asc if ascending else desc
|
81
|
+
query = query.order_by(
|
82
|
+
nulls_last(order_fn(sort_column)) if sort_nulls_last else order_fn(sort_column),
|
83
|
+
order_fn(RunModel.id),
|
84
|
+
)
|
85
|
+
return query
|