letta-nightly 0.8.15.dev20250720104313__py3-none-any.whl → 0.8.16.dev20250721104533__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- letta/__init__.py +1 -1
- letta/agent.py +27 -11
- letta/agents/helpers.py +1 -1
- letta/agents/letta_agent.py +518 -322
- letta/agents/letta_agent_batch.py +1 -2
- letta/agents/voice_agent.py +15 -17
- letta/client/client.py +3 -3
- letta/constants.py +5 -0
- letta/embeddings.py +0 -2
- letta/errors.py +8 -0
- letta/functions/function_sets/base.py +3 -3
- letta/functions/helpers.py +2 -3
- letta/groups/sleeptime_multi_agent.py +0 -1
- letta/helpers/composio_helpers.py +2 -2
- letta/helpers/converters.py +1 -1
- letta/helpers/pinecone_utils.py +8 -0
- letta/helpers/tool_rule_solver.py +13 -18
- letta/llm_api/aws_bedrock.py +16 -2
- letta/llm_api/cohere.py +1 -1
- letta/llm_api/openai_client.py +1 -1
- letta/local_llm/grammars/gbnf_grammar_generator.py +1 -1
- letta/local_llm/llm_chat_completion_wrappers/zephyr.py +14 -14
- letta/local_llm/utils.py +1 -2
- letta/orm/agent.py +3 -3
- letta/orm/block.py +4 -4
- letta/orm/files_agents.py +0 -1
- letta/orm/identity.py +2 -0
- letta/orm/mcp_server.py +0 -2
- letta/orm/message.py +140 -14
- letta/orm/organization.py +5 -5
- letta/orm/passage.py +4 -4
- letta/orm/source.py +1 -1
- letta/orm/sqlalchemy_base.py +61 -39
- letta/orm/step.py +2 -0
- letta/otel/db_pool_monitoring.py +308 -0
- letta/otel/metric_registry.py +94 -1
- letta/otel/sqlalchemy_instrumentation.py +548 -0
- letta/otel/sqlalchemy_instrumentation_integration.py +124 -0
- letta/otel/tracing.py +37 -1
- letta/schemas/agent.py +0 -3
- letta/schemas/agent_file.py +283 -0
- letta/schemas/block.py +0 -3
- letta/schemas/file.py +28 -26
- letta/schemas/letta_message.py +15 -4
- letta/schemas/memory.py +1 -1
- letta/schemas/message.py +31 -26
- letta/schemas/openai/chat_completion_response.py +0 -1
- letta/schemas/providers.py +20 -0
- letta/schemas/source.py +11 -13
- letta/schemas/step.py +12 -0
- letta/schemas/tool.py +0 -4
- letta/serialize_schemas/marshmallow_agent.py +14 -1
- letta/serialize_schemas/marshmallow_block.py +23 -1
- letta/serialize_schemas/marshmallow_message.py +1 -3
- letta/serialize_schemas/marshmallow_tool.py +23 -1
- letta/server/db.py +110 -6
- letta/server/rest_api/app.py +85 -73
- letta/server/rest_api/routers/v1/agents.py +68 -53
- letta/server/rest_api/routers/v1/blocks.py +2 -2
- letta/server/rest_api/routers/v1/jobs.py +3 -0
- letta/server/rest_api/routers/v1/organizations.py +2 -2
- letta/server/rest_api/routers/v1/sources.py +18 -2
- letta/server/rest_api/routers/v1/tools.py +11 -12
- letta/server/rest_api/routers/v1/users.py +1 -1
- letta/server/rest_api/streaming_response.py +13 -5
- letta/server/rest_api/utils.py +8 -25
- letta/server/server.py +11 -4
- letta/server/ws_api/server.py +2 -2
- letta/services/agent_file_manager.py +616 -0
- letta/services/agent_manager.py +133 -46
- letta/services/block_manager.py +38 -17
- letta/services/file_manager.py +106 -21
- letta/services/file_processor/file_processor.py +93 -0
- letta/services/files_agents_manager.py +28 -0
- letta/services/group_manager.py +4 -5
- letta/services/helpers/agent_manager_helper.py +57 -9
- letta/services/identity_manager.py +22 -0
- letta/services/job_manager.py +210 -91
- letta/services/llm_batch_manager.py +9 -6
- letta/services/mcp/stdio_client.py +1 -2
- letta/services/mcp_manager.py +0 -1
- letta/services/message_manager.py +49 -26
- letta/services/passage_manager.py +0 -1
- letta/services/provider_manager.py +1 -1
- letta/services/source_manager.py +114 -5
- letta/services/step_manager.py +36 -4
- letta/services/telemetry_manager.py +9 -2
- letta/services/tool_executor/builtin_tool_executor.py +5 -1
- letta/services/tool_executor/core_tool_executor.py +3 -3
- letta/services/tool_manager.py +95 -20
- letta/services/user_manager.py +4 -12
- letta/settings.py +23 -6
- letta/system.py +1 -1
- letta/utils.py +26 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/METADATA +3 -2
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/RECORD +99 -94
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/LICENSE +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/WHEEL +0 -0
- {letta_nightly-0.8.15.dev20250720104313.dist-info → letta_nightly-0.8.16.dev20250721104533.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,7 @@
|
|
1
1
|
from typing import List
|
2
2
|
|
3
|
+
from mistralai import OCRPageObject, OCRResponse, OCRUsageInfo
|
4
|
+
|
3
5
|
from letta.log import get_logger
|
4
6
|
from letta.otel.context import get_ctx_attributes
|
5
7
|
from letta.otel.tracing import log_event, trace_method
|
@@ -221,3 +223,94 @@ class FileProcessor:
|
|
221
223
|
)
|
222
224
|
|
223
225
|
return []
|
226
|
+
|
227
|
+
def _create_ocr_response_from_content(self, content: str):
|
228
|
+
"""Create minimal OCR response from existing content"""
|
229
|
+
return OCRResponse(
|
230
|
+
model="import-skip-ocr",
|
231
|
+
pages=[
|
232
|
+
OCRPageObject(
|
233
|
+
index=0,
|
234
|
+
markdown=content,
|
235
|
+
images=[],
|
236
|
+
dimensions=None,
|
237
|
+
)
|
238
|
+
],
|
239
|
+
usage_info=OCRUsageInfo(pages_processed=1),
|
240
|
+
document_annotation=None,
|
241
|
+
)
|
242
|
+
|
243
|
+
@trace_method
|
244
|
+
async def process_imported_file(self, file_metadata: FileMetadata, source_id: str) -> List[Passage]:
|
245
|
+
"""Process an imported file that already has content - skip OCR, do chunking/embedding"""
|
246
|
+
filename = file_metadata.file_name
|
247
|
+
|
248
|
+
if not file_metadata.content:
|
249
|
+
logger.warning(f"No content found for imported file {filename}")
|
250
|
+
return []
|
251
|
+
|
252
|
+
content = file_metadata.content
|
253
|
+
try:
|
254
|
+
# Create OCR response from existing content
|
255
|
+
ocr_response = self._create_ocr_response_from_content(content)
|
256
|
+
|
257
|
+
# Update file status to embedding
|
258
|
+
file_metadata = await self.file_manager.update_file_status(
|
259
|
+
file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.EMBEDDING
|
260
|
+
)
|
261
|
+
|
262
|
+
logger.info(f"Chunking imported file content for {filename}")
|
263
|
+
log_event("file_processor.import_chunking_started", {"filename": filename, "content_length": len(content)})
|
264
|
+
|
265
|
+
# Chunk and embed using existing logic
|
266
|
+
all_passages = await self._chunk_and_embed_with_fallback(
|
267
|
+
file_metadata=file_metadata, ocr_response=ocr_response, source_id=source_id
|
268
|
+
)
|
269
|
+
|
270
|
+
# Create passages in database (unless using Pinecone)
|
271
|
+
if not self.using_pinecone:
|
272
|
+
all_passages = await self.passage_manager.create_many_source_passages_async(
|
273
|
+
passages=all_passages, file_metadata=file_metadata, actor=self.actor
|
274
|
+
)
|
275
|
+
log_event("file_processor.import_passages_created", {"filename": filename, "total_passages": len(all_passages)})
|
276
|
+
|
277
|
+
# Update file status to completed
|
278
|
+
if not self.using_pinecone:
|
279
|
+
await self.file_manager.update_file_status(
|
280
|
+
file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.COMPLETED
|
281
|
+
)
|
282
|
+
else:
|
283
|
+
await self.file_manager.update_file_status(
|
284
|
+
file_id=file_metadata.id, actor=self.actor, total_chunks=len(all_passages), chunks_embedded=0
|
285
|
+
)
|
286
|
+
|
287
|
+
logger.info(f"Successfully processed imported file {filename}: {len(all_passages)} passages")
|
288
|
+
log_event(
|
289
|
+
"file_processor.import_processing_completed",
|
290
|
+
{
|
291
|
+
"filename": filename,
|
292
|
+
"file_id": str(file_metadata.id),
|
293
|
+
"total_passages": len(all_passages),
|
294
|
+
"status": FileProcessingStatus.COMPLETED.value,
|
295
|
+
},
|
296
|
+
)
|
297
|
+
|
298
|
+
return all_passages
|
299
|
+
|
300
|
+
except Exception as e:
|
301
|
+
logger.exception("Import file processing failed for %s: %s", filename, e)
|
302
|
+
log_event(
|
303
|
+
"file_processor.import_processing_failed",
|
304
|
+
{
|
305
|
+
"filename": filename,
|
306
|
+
"file_id": str(file_metadata.id),
|
307
|
+
"error": str(e),
|
308
|
+
"error_type": type(e).__name__,
|
309
|
+
"status": FileProcessingStatus.ERROR.value,
|
310
|
+
},
|
311
|
+
)
|
312
|
+
await self.file_manager.update_file_status(
|
313
|
+
file_id=file_metadata.id, actor=self.actor, processing_status=FileProcessingStatus.ERROR, error_message=str(e)
|
314
|
+
)
|
315
|
+
|
316
|
+
return []
|
@@ -573,3 +573,31 @@ class FileAgentManager:
|
|
573
573
|
if not assoc:
|
574
574
|
raise NoResultFound(f"FileAgent(agent_id={agent_id}, file_name={file_name}) not found in org {actor.organization_id}")
|
575
575
|
return assoc
|
576
|
+
|
577
|
+
@enforce_types
|
578
|
+
@trace_method
|
579
|
+
async def get_files_agents_for_agents_async(self, agent_ids: List[str], actor: PydanticUser) -> List[PydanticFileAgent]:
|
580
|
+
"""
|
581
|
+
Get all file-agent relationships for multiple agents in a single query.
|
582
|
+
|
583
|
+
Args:
|
584
|
+
agent_ids: List of agent IDs to find file-agent relationships for
|
585
|
+
actor: User performing the action
|
586
|
+
|
587
|
+
Returns:
|
588
|
+
List[PydanticFileAgent]: List of file-agent relationships for these agents
|
589
|
+
"""
|
590
|
+
if not agent_ids:
|
591
|
+
return []
|
592
|
+
|
593
|
+
async with db_registry.async_session() as session:
|
594
|
+
query = select(FileAgentModel).where(
|
595
|
+
FileAgentModel.agent_id.in_(agent_ids),
|
596
|
+
FileAgentModel.organization_id == actor.organization_id,
|
597
|
+
FileAgentModel.is_deleted == False,
|
598
|
+
)
|
599
|
+
|
600
|
+
result = await session.execute(query)
|
601
|
+
file_agents_orm = result.scalars().all()
|
602
|
+
|
603
|
+
return [file_agent.to_pydantic() for file_agent in file_agents_orm]
|
letta/services/group_manager.py
CHANGED
@@ -18,7 +18,6 @@ from letta.utils import enforce_types
|
|
18
18
|
|
19
19
|
|
20
20
|
class GroupManager:
|
21
|
-
|
22
21
|
@enforce_types
|
23
22
|
@trace_method
|
24
23
|
def list_groups(
|
@@ -164,7 +163,7 @@ class GroupManager:
|
|
164
163
|
manager_agent_id = None
|
165
164
|
if group_update.manager_config:
|
166
165
|
if group_update.manager_config.manager_type != group.manager_type:
|
167
|
-
raise ValueError(
|
166
|
+
raise ValueError("Cannot change group pattern after creation")
|
168
167
|
match group_update.manager_config.manager_type:
|
169
168
|
case ManagerType.round_robin:
|
170
169
|
max_turns = group_update.manager_config.max_turns
|
@@ -473,7 +472,7 @@ class GroupManager:
|
|
473
472
|
# 1) require both-or-none
|
474
473
|
if (max_value is None) != (min_value is None):
|
475
474
|
raise ValueError(
|
476
|
-
f"Both '{max_name}' and '{min_name}' must be provided together
|
475
|
+
f"Both '{max_name}' and '{min_name}' must be provided together (got {max_name}={max_value}, {min_name}={min_value})"
|
477
476
|
)
|
478
477
|
|
479
478
|
# no further checks if neither is provided
|
@@ -488,9 +487,9 @@ class GroupManager:
|
|
488
487
|
)
|
489
488
|
if max_value <= 4 or min_value <= 4:
|
490
489
|
raise ValueError(
|
491
|
-
f"Both '{max_name}' and '{min_name}' must be greater than 4
|
490
|
+
f"Both '{max_name}' and '{min_name}' must be greater than 4 (got {max_name}={max_value}, {min_name}={min_value})"
|
492
491
|
)
|
493
492
|
|
494
493
|
# 3) ordering
|
495
494
|
if max_value <= min_value:
|
496
|
-
raise ValueError(f"'{max_name}' must be greater than '{min_name}'
|
495
|
+
raise ValueError(f"'{max_name}' must be greater than '{min_name}' (got {max_name}={max_value} <= {min_name}={min_value})")
|
@@ -4,6 +4,7 @@ from typing import List, Literal, Optional, Set
|
|
4
4
|
|
5
5
|
import numpy as np
|
6
6
|
from sqlalchemy import Select, and_, asc, desc, func, literal, nulls_last, or_, select, union_all
|
7
|
+
from sqlalchemy.orm import noload
|
7
8
|
from sqlalchemy.sql.expression import exists
|
8
9
|
|
9
10
|
from letta import system
|
@@ -37,7 +38,7 @@ from letta.schemas.memory import Memory
|
|
37
38
|
from letta.schemas.message import Message, MessageCreate
|
38
39
|
from letta.schemas.tool_rule import ToolRule
|
39
40
|
from letta.schemas.user import User
|
40
|
-
from letta.settings import settings
|
41
|
+
from letta.settings import DatabaseChoice, settings
|
41
42
|
from letta.system import get_initial_boot_messages, get_login_event, package_function_response
|
42
43
|
|
43
44
|
|
@@ -382,7 +383,6 @@ def package_initial_message_sequence(
|
|
382
383
|
role=message_create.role,
|
383
384
|
content=[TextContent(text=packed_message)],
|
384
385
|
name=message_create.name,
|
385
|
-
organization_id=actor.organization_id,
|
386
386
|
agent_id=agent_id,
|
387
387
|
model=model,
|
388
388
|
)
|
@@ -397,7 +397,6 @@ def package_initial_message_sequence(
|
|
397
397
|
role=message_create.role,
|
398
398
|
content=[TextContent(text=packed_message)],
|
399
399
|
name=message_create.name,
|
400
|
-
organization_id=actor.organization_id,
|
401
400
|
agent_id=agent_id,
|
402
401
|
model=model,
|
403
402
|
)
|
@@ -418,7 +417,6 @@ def package_initial_message_sequence(
|
|
418
417
|
role=MessageRole.assistant,
|
419
418
|
content=None,
|
420
419
|
name=message_create.name,
|
421
|
-
organization_id=actor.organization_id,
|
422
420
|
agent_id=agent_id,
|
423
421
|
model=model,
|
424
422
|
tool_calls=[
|
@@ -438,7 +436,6 @@ def package_initial_message_sequence(
|
|
438
436
|
role=MessageRole.tool,
|
439
437
|
content=[TextContent(text=function_response)],
|
440
438
|
name=message_create.name,
|
441
|
-
organization_id=actor.organization_id,
|
442
439
|
agent_id=agent_id,
|
443
440
|
model=model,
|
444
441
|
tool_call_id=tool_call_id,
|
@@ -551,6 +548,9 @@ async def _apply_pagination_async(
|
|
551
548
|
result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == after))).first()
|
552
549
|
if result:
|
553
550
|
after_sort_value, after_id = result
|
551
|
+
# SQLite does not support as granular timestamping, so we need to round the timestamp
|
552
|
+
if settings.database_engine is DatabaseChoice.SQLITE and isinstance(after_sort_value, datetime):
|
553
|
+
after_sort_value = after_sort_value.strftime("%Y-%m-%d %H:%M:%S")
|
554
554
|
query = query.where(
|
555
555
|
_cursor_filter(sort_column, AgentModel.id, after_sort_value, after_id, forward=ascending, nulls_last=sort_nulls_last)
|
556
556
|
)
|
@@ -559,6 +559,9 @@ async def _apply_pagination_async(
|
|
559
559
|
result = (await session.execute(select(sort_column, AgentModel.id).where(AgentModel.id == before))).first()
|
560
560
|
if result:
|
561
561
|
before_sort_value, before_id = result
|
562
|
+
# SQLite does not support as granular timestamping, so we need to round the timestamp
|
563
|
+
if settings.database_engine is DatabaseChoice.SQLITE and isinstance(before_sort_value, datetime):
|
564
|
+
before_sort_value = before_sort_value.strftime("%Y-%m-%d %H:%M:%S")
|
562
565
|
query = query.where(
|
563
566
|
_cursor_filter(sort_column, AgentModel.id, before_sort_value, before_id, forward=not ascending, nulls_last=sort_nulls_last)
|
564
567
|
)
|
@@ -649,7 +652,12 @@ def _apply_filters(
|
|
649
652
|
query = query.where(AgentModel.name == name)
|
650
653
|
# Apply a case-insensitive partial match for the agent's name.
|
651
654
|
if query_text:
|
652
|
-
|
655
|
+
if settings.database_engine is DatabaseChoice.POSTGRES:
|
656
|
+
# PostgreSQL: Use ILIKE for case-insensitive search
|
657
|
+
query = query.where(AgentModel.name.ilike(f"%{query_text}%"))
|
658
|
+
else:
|
659
|
+
# SQLite: Use LIKE with LOWER for case-insensitive search
|
660
|
+
query = query.where(func.lower(AgentModel.name).like(func.lower(f"%{query_text}%")))
|
653
661
|
# Filter agents by project ID.
|
654
662
|
if project_id:
|
655
663
|
query = query.where(AgentModel.project_id == project_id)
|
@@ -662,6 +670,24 @@ def _apply_filters(
|
|
662
670
|
return query
|
663
671
|
|
664
672
|
|
673
|
+
def _apply_relationship_filters(query, include_relationships: Optional[List[str]] = None):
|
674
|
+
if include_relationships is None:
|
675
|
+
return query
|
676
|
+
|
677
|
+
if "memory" not in include_relationships:
|
678
|
+
query = query.options(noload(AgentModel.core_memory), noload(AgentModel.file_agents))
|
679
|
+
if "identity_ids" not in include_relationships:
|
680
|
+
query = query.options(noload(AgentModel.identities))
|
681
|
+
|
682
|
+
relationships = ["tool_exec_environment_variables", "tools", "sources", "tags", "multi_agent_group"]
|
683
|
+
|
684
|
+
for rel in relationships:
|
685
|
+
if rel not in include_relationships:
|
686
|
+
query = query.options(noload(getattr(AgentModel, rel)))
|
687
|
+
|
688
|
+
return query
|
689
|
+
|
690
|
+
|
665
691
|
def build_passage_query(
|
666
692
|
actor: User,
|
667
693
|
agent_id: Optional[str] = None,
|
@@ -790,7 +816,7 @@ def build_passage_query(
|
|
790
816
|
|
791
817
|
# Vector search
|
792
818
|
if embedded_text:
|
793
|
-
if settings.
|
819
|
+
if settings.database_engine is DatabaseChoice.POSTGRES:
|
794
820
|
# PostgreSQL with pgvector
|
795
821
|
main_query = main_query.order_by(combined_query.c.embedding.cosine_distance(embedded_text).asc())
|
796
822
|
else:
|
@@ -917,7 +943,7 @@ def build_source_passage_query(
|
|
917
943
|
|
918
944
|
# Handle text search or vector search
|
919
945
|
if embedded_text:
|
920
|
-
if settings.
|
946
|
+
if settings.database_engine is DatabaseChoice.POSTGRES:
|
921
947
|
# PostgreSQL with pgvector
|
922
948
|
query = query.order_by(SourcePassage.embedding.cosine_distance(embedded_text).asc())
|
923
949
|
else:
|
@@ -1004,7 +1030,7 @@ def build_agent_passage_query(
|
|
1004
1030
|
|
1005
1031
|
# Handle text search or vector search
|
1006
1032
|
if embedded_text:
|
1007
|
-
if settings.
|
1033
|
+
if settings.database_engine is DatabaseChoice.POSTGRES:
|
1008
1034
|
# PostgreSQL with pgvector
|
1009
1035
|
query = query.order_by(AgentPassage.embedding.cosine_distance(embedded_text).asc())
|
1010
1036
|
else:
|
@@ -1070,3 +1096,25 @@ def calculate_multi_agent_tools() -> Set[str]:
|
|
1070
1096
|
return set(MULTI_AGENT_TOOLS) - set(LOCAL_ONLY_MULTI_AGENT_TOOLS)
|
1071
1097
|
else:
|
1072
1098
|
return set(MULTI_AGENT_TOOLS)
|
1099
|
+
|
1100
|
+
|
1101
|
+
@trace_method
|
1102
|
+
async def validate_agent_exists_async(session, agent_id: str, actor: User) -> None:
|
1103
|
+
"""
|
1104
|
+
Validate that an agent exists and user has access to it using raw SQL for efficiency.
|
1105
|
+
|
1106
|
+
Args:
|
1107
|
+
session: Database session
|
1108
|
+
agent_id: ID of the agent to validate
|
1109
|
+
actor: User performing the action
|
1110
|
+
|
1111
|
+
Raises:
|
1112
|
+
NoResultFound: If agent doesn't exist or user doesn't have access
|
1113
|
+
"""
|
1114
|
+
agent_exists_query = select(
|
1115
|
+
exists().where(and_(AgentModel.id == agent_id, AgentModel.organization_id == actor.organization_id, AgentModel.is_deleted == False))
|
1116
|
+
)
|
1117
|
+
result = await session.execute(agent_exists_query)
|
1118
|
+
|
1119
|
+
if not result.scalar():
|
1120
|
+
raise NoResultFound(f"Agent with ID {agent_id} not found")
|
@@ -6,12 +6,14 @@ from sqlalchemy.exc import NoResultFound
|
|
6
6
|
|
7
7
|
from letta.orm.agent import Agent as AgentModel
|
8
8
|
from letta.orm.block import Block as BlockModel
|
9
|
+
from letta.orm.errors import UniqueConstraintViolationError
|
9
10
|
from letta.orm.identity import Identity as IdentityModel
|
10
11
|
from letta.otel.tracing import trace_method
|
11
12
|
from letta.schemas.identity import Identity as PydanticIdentity
|
12
13
|
from letta.schemas.identity import IdentityCreate, IdentityProperty, IdentityType, IdentityUpdate, IdentityUpsert
|
13
14
|
from letta.schemas.user import User as PydanticUser
|
14
15
|
from letta.server.db import db_registry
|
16
|
+
from letta.settings import DatabaseChoice, settings
|
15
17
|
from letta.utils import enforce_types
|
16
18
|
|
17
19
|
|
@@ -64,6 +66,26 @@ class IdentityManager:
|
|
64
66
|
async def _create_identity_async(self, db_session, identity: IdentityCreate, actor: PydanticUser) -> PydanticIdentity:
|
65
67
|
new_identity = IdentityModel(**identity.model_dump(exclude={"agent_ids", "block_ids"}, exclude_unset=True))
|
66
68
|
new_identity.organization_id = actor.organization_id
|
69
|
+
|
70
|
+
# For SQLite compatibility: check for unique constraint violation manually
|
71
|
+
# since SQLite doesn't support postgresql_nulls_not_distinct=True
|
72
|
+
if settings.database_engine is DatabaseChoice.SQLITE:
|
73
|
+
# Check if an identity with the same identifier_key, project_id, and organization_id exists
|
74
|
+
query = select(IdentityModel).where(
|
75
|
+
IdentityModel.identifier_key == new_identity.identifier_key,
|
76
|
+
IdentityModel.project_id == new_identity.project_id,
|
77
|
+
IdentityModel.organization_id == new_identity.organization_id,
|
78
|
+
)
|
79
|
+
result = await db_session.execute(query)
|
80
|
+
existing_identity = result.scalar_one_or_none()
|
81
|
+
if existing_identity is not None:
|
82
|
+
raise UniqueConstraintViolationError(
|
83
|
+
f"A unique constraint was violated for Identity. "
|
84
|
+
f"An identity with identifier_key='{new_identity.identifier_key}', "
|
85
|
+
f"project_id='{new_identity.project_id}', and "
|
86
|
+
f"organization_id='{new_identity.organization_id}' already exists."
|
87
|
+
)
|
88
|
+
|
67
89
|
await self._process_relationship_async(
|
68
90
|
db_session=db_session,
|
69
91
|
identity=new_identity,
|