letta-nightly 0.11.7.dev20251008104128__py3-none-any.whl → 0.12.0.dev20251009203644__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. letta/__init__.py +1 -1
  2. letta/agents/letta_agent_v3.py +33 -5
  3. letta/database_utils.py +161 -0
  4. letta/interfaces/anthropic_streaming_interface.py +21 -9
  5. letta/interfaces/gemini_streaming_interface.py +7 -5
  6. letta/interfaces/openai_streaming_interface.py +42 -30
  7. letta/llm_api/anthropic_client.py +36 -16
  8. letta/llm_api/google_vertex_client.py +1 -0
  9. letta/orm/__init__.py +1 -0
  10. letta/orm/run_metrics.py +82 -0
  11. letta/schemas/letta_message.py +29 -12
  12. letta/schemas/message.py +192 -51
  13. letta/schemas/run_metrics.py +21 -0
  14. letta/server/db.py +3 -10
  15. letta/server/rest_api/interface.py +85 -41
  16. letta/server/rest_api/routers/v1/providers.py +34 -0
  17. letta/server/rest_api/routers/v1/runs.py +27 -18
  18. letta/server/server.py +22 -0
  19. letta/services/context_window_calculator/token_counter.py +1 -1
  20. letta/services/helpers/run_manager_helper.py +5 -21
  21. letta/services/run_manager.py +63 -0
  22. letta/system.py +5 -1
  23. {letta_nightly-0.11.7.dev20251008104128.dist-info → letta_nightly-0.12.0.dev20251009203644.dist-info}/METADATA +1 -1
  24. {letta_nightly-0.11.7.dev20251008104128.dist-info → letta_nightly-0.12.0.dev20251009203644.dist-info}/RECORD +27 -24
  25. {letta_nightly-0.11.7.dev20251008104128.dist-info → letta_nightly-0.12.0.dev20251009203644.dist-info}/WHEEL +0 -0
  26. {letta_nightly-0.11.7.dev20251008104128.dist-info → letta_nightly-0.12.0.dev20251009203644.dist-info}/entry_points.txt +0 -0
  27. {letta_nightly-0.11.7.dev20251008104128.dist-info → letta_nightly-0.12.0.dev20251009203644.dist-info}/licenses/LICENSE +0 -0
@@ -190,7 +190,8 @@ class ToolCallMessage(LettaMessage):
190
190
  message_type: Literal[MessageType.tool_call_message] = Field(
191
191
  default=MessageType.tool_call_message, description="The type of the message."
192
192
  )
193
- tool_call: Union[ToolCall, ToolCallDelta]
193
+ tool_call: Union[ToolCall, ToolCallDelta] = Field(..., deprecated=True)
194
+ tool_calls: Optional[Union[List[ToolCall], ToolCallDelta]] = None
194
195
 
195
196
  def model_dump(self, *args, **kwargs):
196
197
  """
@@ -198,8 +199,14 @@ class ToolCallMessage(LettaMessage):
198
199
  """
199
200
  kwargs["exclude_none"] = True
200
201
  data = super().model_dump(*args, **kwargs)
201
- if isinstance(data["tool_call"], dict):
202
+ if isinstance(data.get("tool_call"), dict):
202
203
  data["tool_call"] = {k: v for k, v in data["tool_call"].items() if v is not None}
204
+ if isinstance(data.get("tool_calls"), dict):
205
+ data["tool_calls"] = {k: v for k, v in data["tool_calls"].items() if v is not None}
206
+ elif isinstance(data.get("tool_calls"), list):
207
+ data["tool_calls"] = [
208
+ {k: v for k, v in item.items() if v is not None} if isinstance(item, dict) else item for item in data["tool_calls"]
209
+ ]
203
210
  return data
204
211
 
205
212
  class Config:
@@ -226,6 +233,14 @@ class ToolCallMessage(LettaMessage):
226
233
  return v
227
234
 
228
235
 
236
+ class ToolReturn(BaseModel):
237
+ tool_return: str
238
+ status: Literal["success", "error"]
239
+ tool_call_id: str
240
+ stdout: Optional[List[str]] = None
241
+ stderr: Optional[List[str]] = None
242
+
243
+
229
244
  class ToolReturnMessage(LettaMessage):
230
245
  """
231
246
  A message representing the return value of a tool call (generated by Letta executing the requested tool).
@@ -234,21 +249,23 @@ class ToolReturnMessage(LettaMessage):
234
249
  id (str): The ID of the message
235
250
  date (datetime): The date the message was created in ISO format
236
251
  name (Optional[str]): The name of the sender of the message
237
- tool_return (str): The return value of the tool
238
- status (Literal["success", "error"]): The status of the tool call
239
- tool_call_id (str): A unique identifier for the tool call that generated this message
240
- stdout (Optional[List(str)]): Captured stdout (e.g. prints, logs) from the tool invocation
241
- stderr (Optional[List(str)]): Captured stderr from the tool invocation
252
+ tool_return (str): The return value of the tool (deprecated, use tool_returns)
253
+ status (Literal["success", "error"]): The status of the tool call (deprecated, use tool_returns)
254
+ tool_call_id (str): A unique identifier for the tool call that generated this message (deprecated, use tool_returns)
255
+ stdout (Optional[List(str)]): Captured stdout (e.g. prints, logs) from the tool invocation (deprecated, use tool_returns)
256
+ stderr (Optional[List(str)]): Captured stderr from the tool invocation (deprecated, use tool_returns)
257
+ tool_returns (Optional[List[ToolReturn]]): List of tool returns for multi-tool support
242
258
  """
243
259
 
244
260
  message_type: Literal[MessageType.tool_return_message] = Field(
245
261
  default=MessageType.tool_return_message, description="The type of the message."
246
262
  )
247
- tool_return: str
248
- status: Literal["success", "error"]
249
- tool_call_id: str
250
- stdout: Optional[List[str]] = None
251
- stderr: Optional[List[str]] = None
263
+ tool_return: str = Field(..., deprecated=True)
264
+ status: Literal["success", "error"] = Field(..., deprecated=True)
265
+ tool_call_id: str = Field(..., deprecated=True)
266
+ stdout: Optional[List[str]] = Field(None, deprecated=True)
267
+ stderr: Optional[List[str]] = Field(None, deprecated=True)
268
+ tool_returns: Optional[List[ToolReturn]] = None
252
269
 
253
270
 
254
271
  class ApprovalRequestMessage(LettaMessage):
letta/schemas/message.py CHANGED
@@ -492,23 +492,27 @@ class Message(BaseMessage):
492
492
  assistant_message_tool_kwarg: str = DEFAULT_MESSAGE_TOOL_KWARG,
493
493
  ) -> List[LettaMessage]:
494
494
  messages = []
495
- # This is type FunctionCall
496
- for tool_call in self.tool_calls:
497
- otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
498
- # If we're supporting using assistant message,
499
- # then we want to treat certain function calls as a special case
500
- if use_assistant_message and tool_call.function.name == assistant_message_tool_name:
501
- # We need to unpack the actual message contents from the function call
502
- try:
503
- func_args = parse_json(tool_call.function.arguments)
504
- message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
505
- except KeyError:
506
- raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
495
+
496
+ # If assistant mode is off, just create one ToolCallMessage with all tool calls
497
+ if not use_assistant_message:
498
+ all_tool_call_objs = [
499
+ ToolCall(
500
+ name=tool_call.function.name,
501
+ arguments=tool_call.function.arguments,
502
+ tool_call_id=tool_call.id,
503
+ )
504
+ for tool_call in self.tool_calls
505
+ ]
506
+
507
+ if all_tool_call_objs:
508
+ otid = Message.generate_otid_from_id(self.id, current_message_count)
507
509
  messages.append(
508
- AssistantMessage(
510
+ ToolCallMessage(
509
511
  id=self.id,
510
512
  date=self.created_at,
511
- content=message_string,
513
+ # use first tool call for the deprecated field
514
+ tool_call=all_tool_call_objs[0],
515
+ tool_calls=all_tool_call_objs,
512
516
  name=self.name,
513
517
  otid=otid,
514
518
  sender_id=self.sender_id,
@@ -517,16 +521,41 @@ class Message(BaseMessage):
517
521
  run_id=self.run_id,
518
522
  )
519
523
  )
520
- else:
524
+ return messages
525
+
526
+ collected_tool_calls = []
527
+
528
+ for tool_call in self.tool_calls:
529
+ otid = Message.generate_otid_from_id(self.id, current_message_count + len(messages))
530
+
531
+ if tool_call.function.name == assistant_message_tool_name:
532
+ if collected_tool_calls:
533
+ tool_call_message = ToolCallMessage(
534
+ id=self.id,
535
+ date=self.created_at,
536
+ # use first tool call for the deprecated field
537
+ tool_call=collected_tool_calls[0],
538
+ tool_calls=collected_tool_calls.copy(),
539
+ name=self.name,
540
+ otid=Message.generate_otid_from_id(self.id, current_message_count + len(messages)),
541
+ sender_id=self.sender_id,
542
+ step_id=self.step_id,
543
+ is_err=self.is_err,
544
+ run_id=self.run_id,
545
+ )
546
+ messages.append(tool_call_message)
547
+ collected_tool_calls = [] # reset the collection
548
+
549
+ try:
550
+ func_args = parse_json(tool_call.function.arguments)
551
+ message_string = validate_function_response(func_args[assistant_message_tool_kwarg], 0, truncate=False)
552
+ except KeyError:
553
+ raise ValueError(f"Function call {tool_call.function.name} missing {assistant_message_tool_kwarg} argument")
521
554
  messages.append(
522
- ToolCallMessage(
555
+ AssistantMessage(
523
556
  id=self.id,
524
557
  date=self.created_at,
525
- tool_call=ToolCall(
526
- name=tool_call.function.name,
527
- arguments=tool_call.function.arguments,
528
- tool_call_id=tool_call.id,
529
- ),
558
+ content=message_string,
530
559
  name=self.name,
531
560
  otid=otid,
532
561
  sender_id=self.sender_id,
@@ -535,6 +564,32 @@ class Message(BaseMessage):
535
564
  run_id=self.run_id,
536
565
  )
537
566
  )
567
+ else:
568
+ # non-assistant tool call, collect it
569
+ tool_call_obj = ToolCall(
570
+ name=tool_call.function.name,
571
+ arguments=tool_call.function.arguments,
572
+ tool_call_id=tool_call.id,
573
+ )
574
+ collected_tool_calls.append(tool_call_obj)
575
+
576
+ # flush any remaining collected tool calls
577
+ if collected_tool_calls:
578
+ tool_call_message = ToolCallMessage(
579
+ id=self.id,
580
+ date=self.created_at,
581
+ # use first tool call for the deprecated field
582
+ tool_call=collected_tool_calls[0],
583
+ tool_calls=collected_tool_calls,
584
+ name=self.name,
585
+ otid=Message.generate_otid_from_id(self.id, current_message_count + len(messages)),
586
+ sender_id=self.sender_id,
587
+ step_id=self.step_id,
588
+ is_err=self.is_err,
589
+ run_id=self.run_id,
590
+ )
591
+ messages.append(tool_call_message)
592
+
538
593
  return messages
539
594
 
540
595
  def _convert_tool_return_message(self) -> List[ToolReturnMessage]:
@@ -556,6 +611,13 @@ class Message(BaseMessage):
556
611
  if self.role != MessageRole.tool:
557
612
  raise ValueError(f"Cannot convert message of type {self.role} to ToolReturnMessage")
558
613
 
614
+ # This is a very special buggy case during the double writing period
615
+ # where there is no tool call id on the tool return object, but it exists top level
616
+ # This is meant to be a short term patch - this can happen when people are using old agent files that were exported
617
+ # during a specific migration state
618
+ if len(self.tool_returns) == 1 and self.tool_call_id and not self.tool_returns[0].tool_call_id:
619
+ self.tool_returns[0].tool_call_id = self.tool_call_id
620
+
559
621
  if self.tool_returns:
560
622
  return self._convert_explicit_tool_returns()
561
623
 
@@ -647,6 +709,16 @@ class Message(BaseMessage):
647
709
  Returns:
648
710
  Configured ToolReturnMessage instance
649
711
  """
712
+ from letta.schemas.letta_message import ToolReturn as ToolReturnSchema
713
+
714
+ tool_return_obj = ToolReturnSchema(
715
+ tool_return=message_text,
716
+ status=status,
717
+ tool_call_id=tool_call_id,
718
+ stdout=stdout,
719
+ stderr=stderr,
720
+ )
721
+
650
722
  return ToolReturnMessage(
651
723
  id=self.id,
652
724
  date=self.created_at,
@@ -655,6 +727,7 @@ class Message(BaseMessage):
655
727
  tool_call_id=tool_call_id,
656
728
  stdout=stdout,
657
729
  stderr=stderr,
730
+ tool_returns=[tool_return_obj],
658
731
  name=self.name,
659
732
  otid=Message.generate_otid_from_id(self.id, otid_index),
660
733
  sender_id=self.sender_id,
@@ -965,7 +1038,13 @@ class Message(BaseMessage):
965
1038
  }
966
1039
 
967
1040
  elif self.role == "assistant" or self.role == "approval":
968
- assert self.tool_calls is not None or text_content is not None, vars(self)
1041
+ try:
1042
+ assert self.tool_calls is not None or text_content is not None, vars(self)
1043
+ except AssertionError as e:
1044
+ # relax check if this message only contains reasoning content
1045
+ if self.content is not None and len(self.content) > 0 and isinstance(self.content[0], ReasoningContent):
1046
+ return None
1047
+ raise e
969
1048
 
970
1049
  # if native content, then put it directly inside the content
971
1050
  if native_content:
@@ -1040,6 +1119,7 @@ class Message(BaseMessage):
1040
1119
  put_inner_thoughts_in_kwargs: bool = False,
1041
1120
  use_developer_message: bool = False,
1042
1121
  ) -> List[dict]:
1122
+ messages = Message.filter_messages_for_llm_api(messages)
1043
1123
  result = [
1044
1124
  m.to_openai_dict(
1045
1125
  max_tool_id_length=max_tool_id_length,
@@ -1149,6 +1229,7 @@ class Message(BaseMessage):
1149
1229
  messages: List[Message],
1150
1230
  max_tool_id_length: int = TOOL_CALL_ID_MAX_LEN,
1151
1231
  ) -> List[dict]:
1232
+ messages = Message.filter_messages_for_llm_api(messages)
1152
1233
  result = []
1153
1234
  for message in messages:
1154
1235
  result.extend(message.to_openai_responses_dicts(max_tool_id_length=max_tool_id_length))
@@ -1156,6 +1237,7 @@ class Message(BaseMessage):
1156
1237
 
1157
1238
  def to_anthropic_dict(
1158
1239
  self,
1240
+ current_model: str,
1159
1241
  inner_thoughts_xml_tag="thinking",
1160
1242
  put_inner_thoughts_in_kwargs: bool = False,
1161
1243
  # if true, then treat the content field as AssistantMessage
@@ -1242,20 +1324,22 @@ class Message(BaseMessage):
1242
1324
  for content_part in self.content:
1243
1325
  # TextContent, ImageContent, ToolCallContent, ToolReturnContent, ReasoningContent, RedactedReasoningContent, OmittedReasoningContent
1244
1326
  if isinstance(content_part, ReasoningContent):
1245
- content.append(
1246
- {
1247
- "type": "thinking",
1248
- "thinking": content_part.reasoning,
1249
- "signature": content_part.signature,
1250
- }
1251
- )
1327
+ if current_model == self.model:
1328
+ content.append(
1329
+ {
1330
+ "type": "thinking",
1331
+ "thinking": content_part.reasoning,
1332
+ "signature": content_part.signature,
1333
+ }
1334
+ )
1252
1335
  elif isinstance(content_part, RedactedReasoningContent):
1253
- content.append(
1254
- {
1255
- "type": "redacted_thinking",
1256
- "data": content_part.data,
1257
- }
1258
- )
1336
+ if current_model == self.model:
1337
+ content.append(
1338
+ {
1339
+ "type": "redacted_thinking",
1340
+ "data": content_part.data,
1341
+ }
1342
+ )
1259
1343
  elif isinstance(content_part, TextContent):
1260
1344
  content.append(
1261
1345
  {
@@ -1272,20 +1356,22 @@ class Message(BaseMessage):
1272
1356
  if self.content is not None and len(self.content) >= 1:
1273
1357
  for content_part in self.content:
1274
1358
  if isinstance(content_part, ReasoningContent):
1275
- content.append(
1276
- {
1277
- "type": "thinking",
1278
- "thinking": content_part.reasoning,
1279
- "signature": content_part.signature,
1280
- }
1281
- )
1359
+ if current_model == self.model:
1360
+ content.append(
1361
+ {
1362
+ "type": "thinking",
1363
+ "thinking": content_part.reasoning,
1364
+ "signature": content_part.signature,
1365
+ }
1366
+ )
1282
1367
  if isinstance(content_part, RedactedReasoningContent):
1283
- content.append(
1284
- {
1285
- "type": "redacted_thinking",
1286
- "data": content_part.data,
1287
- }
1288
- )
1368
+ if current_model == self.model:
1369
+ content.append(
1370
+ {
1371
+ "type": "redacted_thinking",
1372
+ "data": content_part.data,
1373
+ }
1374
+ )
1289
1375
  if isinstance(content_part, TextContent):
1290
1376
  content.append(
1291
1377
  {
@@ -1349,14 +1435,17 @@ class Message(BaseMessage):
1349
1435
  @staticmethod
1350
1436
  def to_anthropic_dicts_from_list(
1351
1437
  messages: List[Message],
1438
+ current_model: str,
1352
1439
  inner_thoughts_xml_tag: str = "thinking",
1353
1440
  put_inner_thoughts_in_kwargs: bool = False,
1354
1441
  # if true, then treat the content field as AssistantMessage
1355
1442
  native_content: bool = False,
1356
1443
  strip_request_heartbeat: bool = False,
1357
1444
  ) -> List[dict]:
1445
+ messages = Message.filter_messages_for_llm_api(messages)
1358
1446
  result = [
1359
1447
  m.to_anthropic_dict(
1448
+ current_model=current_model,
1360
1449
  inner_thoughts_xml_tag=inner_thoughts_xml_tag,
1361
1450
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1362
1451
  native_content=native_content,
@@ -1369,6 +1458,7 @@ class Message(BaseMessage):
1369
1458
 
1370
1459
  def to_google_dict(
1371
1460
  self,
1461
+ current_model: str,
1372
1462
  put_inner_thoughts_in_kwargs: bool = True,
1373
1463
  # if true, then treat the content field as AssistantMessage
1374
1464
  native_content: bool = False,
@@ -1484,11 +1574,12 @@ class Message(BaseMessage):
1484
1574
  for content in self.content:
1485
1575
  if isinstance(content, TextContent):
1486
1576
  native_part = {"text": content.text}
1487
- if content.signature:
1577
+ if content.signature and current_model == self.model:
1488
1578
  native_part["thought_signature"] = content.signature
1489
1579
  native_google_content_parts.append(native_part)
1490
1580
  elif isinstance(content, ReasoningContent):
1491
- native_google_content_parts.append({"text": content.reasoning, "thought": True})
1581
+ if current_model == self.model:
1582
+ native_google_content_parts.append({"text": content.reasoning, "thought": True})
1492
1583
  elif isinstance(content, ToolCallContent):
1493
1584
  native_part = {
1494
1585
  "function_call": {
@@ -1496,7 +1587,7 @@ class Message(BaseMessage):
1496
1587
  "args": content.input,
1497
1588
  },
1498
1589
  }
1499
- if content.signature:
1590
+ if content.signature and current_model == self.model:
1500
1591
  native_part["thought_signature"] = content.signature
1501
1592
  native_google_content_parts.append(native_part)
1502
1593
  else:
@@ -1554,11 +1645,14 @@ class Message(BaseMessage):
1554
1645
  @staticmethod
1555
1646
  def to_google_dicts_from_list(
1556
1647
  messages: List[Message],
1648
+ current_model: str,
1557
1649
  put_inner_thoughts_in_kwargs: bool = True,
1558
1650
  native_content: bool = False,
1559
1651
  ):
1652
+ messages = Message.filter_messages_for_llm_api(messages)
1560
1653
  result = [
1561
1654
  m.to_google_dict(
1655
+ current_model=current_model,
1562
1656
  put_inner_thoughts_in_kwargs=put_inner_thoughts_in_kwargs,
1563
1657
  native_content=native_content,
1564
1658
  )
@@ -1567,6 +1661,53 @@ class Message(BaseMessage):
1567
1661
  result = [m for m in result if m is not None]
1568
1662
  return result
1569
1663
 
1664
+ def is_approval_request(self) -> bool:
1665
+ return self.role == "approval" and self.tool_calls is not None and len(self.tool_calls) > 0
1666
+
1667
+ def is_approval_response(self) -> bool:
1668
+ return self.role == "approval" and self.tool_calls is None and self.approve is not None
1669
+
1670
+ def is_summarization_message(self) -> bool:
1671
+ return (
1672
+ self.role == "user"
1673
+ and self.content is not None
1674
+ and len(self.content) == 1
1675
+ and isinstance(self.content[0], TextContent)
1676
+ and "system_alert" in self.content[0].text
1677
+ )
1678
+
1679
+ @staticmethod
1680
+ def filter_messages_for_llm_api(
1681
+ messages: List[Message],
1682
+ ) -> List[Message]:
1683
+ messages = [m for m in messages if m is not None]
1684
+ if len(messages) == 0:
1685
+ return []
1686
+ # Add special handling for legacy bug where summarization triggers in the middle of hitl
1687
+ messages_to_filter = []
1688
+ for i in range(len(messages) - 1):
1689
+ first_message_is_approval = messages[i].is_approval_request()
1690
+ second_message_is_summary = messages[i + 1].is_summarization_message()
1691
+ third_message_is_optional_approval = i + 2 >= len(messages) or messages[i + 2].is_approval_response()
1692
+ if first_message_is_approval and second_message_is_summary and third_message_is_optional_approval:
1693
+ messages_to_filter.append(messages[i])
1694
+ for idx in reversed(messages_to_filter): # reverse to avoid index shift
1695
+ messages.remove(idx)
1696
+
1697
+ # Filter last message if it is a lone approval request without a response - this only occurs for token counting
1698
+ if messages[-1].role == "approval" and messages[-1].tool_calls is not None and len(messages[-1].tool_calls) > 0:
1699
+ messages.remove(messages[-1])
1700
+
1701
+ # Filter last message if it is a lone reasoning message without assistant message or tool call
1702
+ if (
1703
+ messages[-1].role == "assistant"
1704
+ and messages[-1].tool_calls is None
1705
+ and (not messages[-1].content or all(not isinstance(content_part, TextContent) for content_part in messages[-1].content))
1706
+ ):
1707
+ messages.remove(messages[-1])
1708
+
1709
+ return messages
1710
+
1570
1711
  @staticmethod
1571
1712
  def generate_otid_from_id(message_id: str, index: int) -> str:
1572
1713
  """
@@ -0,0 +1,21 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import Field
4
+
5
+ from letta.schemas.letta_base import LettaBase
6
+
7
+
8
+ class RunMetricsBase(LettaBase):
9
+ __id_prefix__ = "run"
10
+
11
+
12
+ class RunMetrics(RunMetricsBase):
13
+ id: str = Field(..., description="The id of the run this metric belongs to (matches runs.id).")
14
+ organization_id: Optional[str] = Field(None, description="The unique identifier of the organization.")
15
+ agent_id: Optional[str] = Field(None, description="The unique identifier of the agent.")
16
+ project_id: Optional[str] = Field(None, description="The project that the run belongs to (cloud only).")
17
+ run_start_ns: Optional[int] = Field(None, description="The timestamp of the start of the run in nanoseconds.")
18
+ run_ns: Optional[int] = Field(None, description="Total time for the run in nanoseconds.")
19
+ num_steps: Optional[int] = Field(None, description="The number of steps in the run.")
20
+ template_id: Optional[str] = Field(None, description="The template ID that the run belongs to (cloud only).")
21
+ base_template_id: Optional[str] = Field(None, description="The base template ID that the run belongs to (cloud only).")
letta/server/db.py CHANGED
@@ -10,18 +10,11 @@ from sqlalchemy.ext.asyncio import (
10
10
  create_async_engine,
11
11
  )
12
12
 
13
+ from letta.database_utils import get_database_uri_for_context
13
14
  from letta.settings import settings
14
15
 
15
- # Convert PostgreSQL URI to async format
16
- pg_uri = settings.letta_pg_uri
17
- if pg_uri.startswith("postgresql://"):
18
- async_pg_uri = pg_uri.replace("postgresql://", "postgresql+asyncpg://")
19
- else:
20
- # Handle other URI formats (e.g., postgresql+pg8000://)
21
- async_pg_uri = f"postgresql+asyncpg://{pg_uri.split('://', 1)[1]}" if "://" in pg_uri else pg_uri
22
-
23
- # Replace sslmode with ssl for asyncpg
24
- async_pg_uri = async_pg_uri.replace("sslmode=", "ssl=")
16
+ # Convert PostgreSQL URI to async format using common utility
17
+ async_pg_uri = get_database_uri_for_context(settings.letta_pg_uri, "async")
25
18
 
26
19
  # Build engine configuration based on settings
27
20
  engine_args = {