letta-nightly 0.12.1.dev20251023104211__py3-none-any.whl → 0.13.0.dev20251024223017__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (159) hide show
  1. letta/__init__.py +2 -3
  2. letta/adapters/letta_llm_adapter.py +1 -0
  3. letta/adapters/simple_llm_request_adapter.py +8 -5
  4. letta/adapters/simple_llm_stream_adapter.py +22 -6
  5. letta/agents/agent_loop.py +10 -3
  6. letta/agents/base_agent.py +4 -1
  7. letta/agents/helpers.py +41 -9
  8. letta/agents/letta_agent.py +11 -10
  9. letta/agents/letta_agent_v2.py +47 -37
  10. letta/agents/letta_agent_v3.py +395 -300
  11. letta/agents/voice_agent.py +8 -6
  12. letta/agents/voice_sleeptime_agent.py +3 -3
  13. letta/constants.py +30 -7
  14. letta/errors.py +20 -0
  15. letta/functions/function_sets/base.py +55 -3
  16. letta/functions/mcp_client/types.py +33 -57
  17. letta/functions/schema_generator.py +135 -23
  18. letta/groups/sleeptime_multi_agent_v3.py +6 -11
  19. letta/groups/sleeptime_multi_agent_v4.py +227 -0
  20. letta/helpers/converters.py +78 -4
  21. letta/helpers/crypto_utils.py +6 -2
  22. letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py +9 -11
  23. letta/interfaces/anthropic_streaming_interface.py +3 -4
  24. letta/interfaces/gemini_streaming_interface.py +4 -6
  25. letta/interfaces/openai_streaming_interface.py +63 -28
  26. letta/llm_api/anthropic_client.py +7 -4
  27. letta/llm_api/deepseek_client.py +6 -4
  28. letta/llm_api/google_ai_client.py +3 -12
  29. letta/llm_api/google_vertex_client.py +1 -1
  30. letta/llm_api/helpers.py +90 -61
  31. letta/llm_api/llm_api_tools.py +4 -1
  32. letta/llm_api/openai.py +12 -12
  33. letta/llm_api/openai_client.py +53 -16
  34. letta/local_llm/constants.py +4 -3
  35. letta/local_llm/json_parser.py +5 -2
  36. letta/local_llm/utils.py +2 -3
  37. letta/log.py +171 -7
  38. letta/orm/agent.py +43 -9
  39. letta/orm/archive.py +4 -0
  40. letta/orm/custom_columns.py +15 -0
  41. letta/orm/identity.py +11 -11
  42. letta/orm/mcp_server.py +9 -0
  43. letta/orm/message.py +6 -1
  44. letta/orm/run_metrics.py +7 -2
  45. letta/orm/sqlalchemy_base.py +2 -2
  46. letta/orm/tool.py +3 -0
  47. letta/otel/tracing.py +2 -0
  48. letta/prompts/prompt_generator.py +7 -2
  49. letta/schemas/agent.py +41 -10
  50. letta/schemas/agent_file.py +3 -0
  51. letta/schemas/archive.py +4 -2
  52. letta/schemas/block.py +2 -1
  53. letta/schemas/enums.py +36 -3
  54. letta/schemas/file.py +3 -3
  55. letta/schemas/folder.py +2 -1
  56. letta/schemas/group.py +2 -1
  57. letta/schemas/identity.py +18 -9
  58. letta/schemas/job.py +3 -1
  59. letta/schemas/letta_message.py +71 -12
  60. letta/schemas/letta_request.py +7 -3
  61. letta/schemas/letta_stop_reason.py +0 -25
  62. letta/schemas/llm_config.py +8 -2
  63. letta/schemas/mcp.py +80 -83
  64. letta/schemas/mcp_server.py +349 -0
  65. letta/schemas/memory.py +20 -8
  66. letta/schemas/message.py +212 -67
  67. letta/schemas/providers/anthropic.py +13 -6
  68. letta/schemas/providers/azure.py +6 -4
  69. letta/schemas/providers/base.py +8 -4
  70. letta/schemas/providers/bedrock.py +6 -2
  71. letta/schemas/providers/cerebras.py +7 -3
  72. letta/schemas/providers/deepseek.py +2 -1
  73. letta/schemas/providers/google_gemini.py +15 -6
  74. letta/schemas/providers/groq.py +2 -1
  75. letta/schemas/providers/lmstudio.py +9 -6
  76. letta/schemas/providers/mistral.py +2 -1
  77. letta/schemas/providers/openai.py +7 -2
  78. letta/schemas/providers/together.py +9 -3
  79. letta/schemas/providers/xai.py +7 -3
  80. letta/schemas/run.py +7 -2
  81. letta/schemas/run_metrics.py +2 -1
  82. letta/schemas/sandbox_config.py +2 -2
  83. letta/schemas/secret.py +3 -158
  84. letta/schemas/source.py +2 -2
  85. letta/schemas/step.py +2 -2
  86. letta/schemas/tool.py +24 -1
  87. letta/schemas/usage.py +0 -1
  88. letta/server/rest_api/app.py +123 -7
  89. letta/server/rest_api/dependencies.py +3 -0
  90. letta/server/rest_api/interface.py +7 -4
  91. letta/server/rest_api/redis_stream_manager.py +16 -1
  92. letta/server/rest_api/routers/v1/__init__.py +7 -0
  93. letta/server/rest_api/routers/v1/agents.py +332 -322
  94. letta/server/rest_api/routers/v1/archives.py +127 -40
  95. letta/server/rest_api/routers/v1/blocks.py +54 -6
  96. letta/server/rest_api/routers/v1/chat_completions.py +146 -0
  97. letta/server/rest_api/routers/v1/folders.py +27 -35
  98. letta/server/rest_api/routers/v1/groups.py +23 -35
  99. letta/server/rest_api/routers/v1/identities.py +24 -10
  100. letta/server/rest_api/routers/v1/internal_runs.py +107 -0
  101. letta/server/rest_api/routers/v1/internal_templates.py +162 -179
  102. letta/server/rest_api/routers/v1/jobs.py +15 -27
  103. letta/server/rest_api/routers/v1/mcp_servers.py +309 -0
  104. letta/server/rest_api/routers/v1/messages.py +23 -34
  105. letta/server/rest_api/routers/v1/organizations.py +6 -27
  106. letta/server/rest_api/routers/v1/providers.py +35 -62
  107. letta/server/rest_api/routers/v1/runs.py +30 -43
  108. letta/server/rest_api/routers/v1/sandbox_configs.py +6 -4
  109. letta/server/rest_api/routers/v1/sources.py +26 -42
  110. letta/server/rest_api/routers/v1/steps.py +16 -29
  111. letta/server/rest_api/routers/v1/tools.py +17 -13
  112. letta/server/rest_api/routers/v1/users.py +5 -17
  113. letta/server/rest_api/routers/v1/voice.py +18 -27
  114. letta/server/rest_api/streaming_response.py +5 -2
  115. letta/server/rest_api/utils.py +187 -25
  116. letta/server/server.py +27 -22
  117. letta/server/ws_api/server.py +5 -4
  118. letta/services/agent_manager.py +148 -26
  119. letta/services/agent_serialization_manager.py +6 -1
  120. letta/services/archive_manager.py +168 -15
  121. letta/services/block_manager.py +14 -4
  122. letta/services/file_manager.py +33 -29
  123. letta/services/group_manager.py +10 -0
  124. letta/services/helpers/agent_manager_helper.py +65 -11
  125. letta/services/identity_manager.py +105 -4
  126. letta/services/job_manager.py +11 -1
  127. letta/services/mcp/base_client.py +2 -2
  128. letta/services/mcp/oauth_utils.py +33 -8
  129. letta/services/mcp_manager.py +174 -78
  130. letta/services/mcp_server_manager.py +1331 -0
  131. letta/services/message_manager.py +109 -4
  132. letta/services/organization_manager.py +4 -4
  133. letta/services/passage_manager.py +9 -25
  134. letta/services/provider_manager.py +91 -15
  135. letta/services/run_manager.py +72 -15
  136. letta/services/sandbox_config_manager.py +45 -3
  137. letta/services/source_manager.py +15 -8
  138. letta/services/step_manager.py +24 -1
  139. letta/services/streaming_service.py +581 -0
  140. letta/services/summarizer/summarizer.py +1 -1
  141. letta/services/tool_executor/core_tool_executor.py +111 -0
  142. letta/services/tool_executor/files_tool_executor.py +5 -3
  143. letta/services/tool_executor/sandbox_tool_executor.py +2 -2
  144. letta/services/tool_executor/tool_execution_manager.py +1 -1
  145. letta/services/tool_manager.py +10 -3
  146. letta/services/tool_sandbox/base.py +61 -1
  147. letta/services/tool_sandbox/local_sandbox.py +1 -3
  148. letta/services/user_manager.py +2 -2
  149. letta/settings.py +49 -5
  150. letta/system.py +14 -5
  151. letta/utils.py +73 -1
  152. letta/validators.py +105 -0
  153. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/METADATA +4 -2
  154. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/RECORD +157 -151
  155. letta/schemas/letta_ping.py +0 -28
  156. letta/server/rest_api/routers/openai/chat_completions/__init__.py +0 -0
  157. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/WHEEL +0 -0
  158. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/entry_points.txt +0 -0
  159. {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/licenses/LICENSE +0 -0
@@ -9,6 +9,9 @@ import requests
9
9
  from letta.constants import CLI_WARNING_PREFIX
10
10
  from letta.errors import LettaConfigurationError, RateLimitExceededError
11
11
  from letta.llm_api.helpers import unpack_all_inner_thoughts_from_kwargs
12
+ from letta.log import get_logger
13
+
14
+ logger = get_logger(__name__)
12
15
  from letta.llm_api.openai import (
13
16
  build_openai_chat_completions_request,
14
17
  openai_chat_completions_process_stream,
@@ -95,7 +98,7 @@ def retry_with_exponential_backoff(
95
98
 
96
99
  # Sleep for the delay
97
100
  # printd(f"Got a rate limit error ('{http_err}') on LLM backend request, waiting {int(delay)}s then retrying...")
98
- print(
101
+ logger.warning(
99
102
  f"{CLI_WARNING_PREFIX}Got a rate limit error ('{http_err}') on LLM backend request, waiting {int(delay)}s then retrying..."
100
103
  )
101
104
  time.sleep(delay)
letta/llm_api/openai.py CHANGED
@@ -1,4 +1,3 @@
1
- import warnings
2
1
  from typing import Generator, List, Optional, Union
3
2
 
4
3
  import httpx
@@ -70,9 +69,10 @@ def openai_get_model_list(url: str, api_key: Optional[str] = None, fix_url: bool
70
69
  # In Letta config the address for vLLM is w/o a /v1 suffix for simplicity
71
70
  # However if we're treating the server as an OpenAI proxy we want the /v1 suffix on our model hit
72
71
 
73
- import warnings
74
-
75
- warnings.warn("The synchronous version of openai_get_model_list function is deprecated. Use the async one instead.", DeprecationWarning)
72
+ logger.warning(
73
+ "The synchronous version of openai_get_model_list function is deprecated. Use the async one instead.",
74
+ stacklevel=2,
75
+ )
76
76
 
77
77
  if fix_url:
78
78
  if not url.endswith("/v1"):
@@ -224,7 +224,7 @@ def build_openai_chat_completions_request(
224
224
  if llm_config.model:
225
225
  model = llm_config.model
226
226
  else:
227
- warnings.warn(f"Model type not set in llm_config: {llm_config.model_dump_json(indent=4)}")
227
+ logger.warning(f"Model type not set in llm_config: {llm_config.model_dump_json(indent=4)}")
228
228
  model = None
229
229
 
230
230
  if use_tool_naming:
@@ -285,7 +285,7 @@ def build_openai_chat_completions_request(
285
285
  structured_output_version = convert_to_structured_output(tool.function.model_dump())
286
286
  tool.function = FunctionSchema(**structured_output_version)
287
287
  except ValueError as e:
288
- warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
288
+ logger.warning(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
289
289
  return data
290
290
 
291
291
 
@@ -377,7 +377,7 @@ def openai_chat_completions_process_stream(
377
377
  ):
378
378
  assert isinstance(chat_completion_chunk, ChatCompletionChunkResponse), type(chat_completion_chunk)
379
379
  if chat_completion_chunk.choices is None or len(chat_completion_chunk.choices) == 0:
380
- warnings.warn(f"No choices in chunk: {chat_completion_chunk}")
380
+ logger.warning(f"No choices in chunk: {chat_completion_chunk}")
381
381
  continue
382
382
 
383
383
  # NOTE: this assumes that the tool call ID will only appear in one of the chunks during the stream
@@ -472,7 +472,7 @@ def openai_chat_completions_process_stream(
472
472
  try:
473
473
  accum_message.tool_calls[tool_call_delta.index].id = tool_call_delta.id
474
474
  except IndexError:
475
- warnings.warn(
475
+ logger.warning(
476
476
  f"Tool call index out of range ({tool_call_delta.index})\ncurrent tool calls: {accum_message.tool_calls}\ncurrent delta: {tool_call_delta}"
477
477
  )
478
478
  # force index 0
@@ -486,14 +486,14 @@ def openai_chat_completions_process_stream(
486
486
  tool_call_delta.index
487
487
  ].function.name += tool_call_delta.function.name # TODO check for parallel tool calls
488
488
  except IndexError:
489
- warnings.warn(
489
+ logger.warning(
490
490
  f"Tool call index out of range ({tool_call_delta.index})\ncurrent tool calls: {accum_message.tool_calls}\ncurrent delta: {tool_call_delta}"
491
491
  )
492
492
  if tool_call_delta.function.arguments is not None:
493
493
  try:
494
494
  accum_message.tool_calls[tool_call_delta.index].function.arguments += tool_call_delta.function.arguments
495
495
  except IndexError:
496
- warnings.warn(
496
+ logger.warning(
497
497
  f"Tool call index out of range ({tool_call_delta.index})\ncurrent tool calls: {accum_message.tool_calls}\ncurrent delta: {tool_call_delta}"
498
498
  )
499
499
 
@@ -578,7 +578,7 @@ def openai_chat_completions_request_stream(
578
578
  # TODO: Use the native OpenAI objects here?
579
579
  yield ChatCompletionChunkResponse(**chunk.model_dump(exclude_none=True))
580
580
  except Exception as e:
581
- print(f"Error request stream from /v1/chat/completions, url={url}, data={data}:\n{e}")
581
+ logger.error(f"Error request stream from /v1/chat/completions, url={url}, data={data}: {e}")
582
582
  raise e
583
583
 
584
584
 
@@ -642,7 +642,7 @@ def prepare_openai_payload(chat_completion_request: ChatCompletionRequest):
642
642
  # try:
643
643
  # tool["function"] = convert_to_structured_output(tool["function"])
644
644
  # except ValueError as e:
645
- # warnings.warn(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
645
+ # logger.warning(f"Failed to convert tool function to structured output, tool={tool}, error={e}")
646
646
 
647
647
  if not supports_parallel_tool_calling(chat_completion_request.model):
648
648
  data.pop("parallel_tool_calls", None)
@@ -649,6 +649,24 @@ class OpenAIClient(LLMClientBase):
649
649
  # We just need to instantiate the Pydantic model for validation and type safety.
650
650
  chat_completion_response = ChatCompletionResponse(**response_data)
651
651
  chat_completion_response = self._fix_truncated_json_response(chat_completion_response)
652
+
653
+ # Parse reasoning_content from vLLM/OpenRouter/OpenAI proxies that return this field
654
+ # This handles cases where the proxy returns .reasoning_content in the response
655
+ if (
656
+ chat_completion_response.choices
657
+ and len(chat_completion_response.choices) > 0
658
+ and chat_completion_response.choices[0].message
659
+ and not chat_completion_response.choices[0].message.reasoning_content
660
+ ):
661
+ if "choices" in response_data and len(response_data["choices"]) > 0:
662
+ choice_data = response_data["choices"][0]
663
+ if "message" in choice_data and "reasoning_content" in choice_data["message"]:
664
+ reasoning_content = choice_data["message"]["reasoning_content"]
665
+ if reasoning_content:
666
+ chat_completion_response.choices[0].message.reasoning_content = reasoning_content
667
+
668
+ chat_completion_response.choices[0].message.reasoning_content_signature = None
669
+
652
670
  # Unpack inner thoughts if they were embedded in function arguments
653
671
  if llm_config.put_inner_thoughts_in_kwargs:
654
672
  chat_completion_response = unpack_all_inner_thoughts_from_kwargs(
@@ -696,7 +714,13 @@ class OpenAIClient(LLMClientBase):
696
714
 
697
715
  @trace_method
698
716
  async def request_embeddings(self, inputs: List[str], embedding_config: EmbeddingConfig) -> List[List[float]]:
699
- """Request embeddings given texts and embedding config with chunking and retry logic"""
717
+ """Request embeddings given texts and embedding config with chunking and retry logic
718
+
719
+ Retry strategy prioritizes reducing batch size before chunk size to maintain retrieval quality:
720
+ 1. Start with batch_size=2048 (texts per request)
721
+ 2. On failure, halve batch_size until it reaches 1
722
+ 3. Only then start reducing chunk_size (for very large individual texts)
723
+ """
700
724
  if not inputs:
701
725
  return []
702
726
 
@@ -705,35 +729,48 @@ class OpenAIClient(LLMClientBase):
705
729
 
706
730
  # track results by original index to maintain order
707
731
  results = [None] * len(inputs)
708
-
709
- # queue of (start_idx, chunk_inputs) to process
710
- chunks_to_process = [(i, inputs[i : i + 2048]) for i in range(0, len(inputs), 2048)]
711
-
712
- min_chunk_size = 256
732
+ initial_batch_size = 2048
733
+ chunks_to_process = [(i, inputs[i : i + initial_batch_size], initial_batch_size) for i in range(0, len(inputs), initial_batch_size)]
734
+ min_chunk_size = 128
713
735
 
714
736
  while chunks_to_process:
715
737
  tasks = []
716
738
  task_metadata = []
717
739
 
718
- for start_idx, chunk_inputs in chunks_to_process:
740
+ for start_idx, chunk_inputs, current_batch_size in chunks_to_process:
719
741
  task = client.embeddings.create(model=embedding_config.embedding_model, input=chunk_inputs)
720
742
  tasks.append(task)
721
- task_metadata.append((start_idx, chunk_inputs))
743
+ task_metadata.append((start_idx, chunk_inputs, current_batch_size))
722
744
 
723
745
  task_results = await asyncio.gather(*tasks, return_exceptions=True)
724
746
 
725
747
  failed_chunks = []
726
- for (start_idx, chunk_inputs), result in zip(task_metadata, task_results):
748
+ for (start_idx, chunk_inputs, current_batch_size), result in zip(task_metadata, task_results):
727
749
  if isinstance(result, Exception):
728
- # check if we can retry with smaller chunks
729
- if len(chunk_inputs) > min_chunk_size:
730
- # split chunk in half and queue for retry
750
+ current_size = len(chunk_inputs)
751
+
752
+ if current_batch_size > 1:
753
+ new_batch_size = max(1, current_batch_size // 2)
754
+ logger.warning(
755
+ f"Embeddings request failed for batch starting at {start_idx} with size {current_size}. "
756
+ f"Reducing batch size from {current_batch_size} to {new_batch_size} and retrying."
757
+ )
731
758
  mid = len(chunk_inputs) // 2
732
- failed_chunks.append((start_idx, chunk_inputs[:mid]))
733
- failed_chunks.append((start_idx + mid, chunk_inputs[mid:]))
759
+ failed_chunks.append((start_idx, chunk_inputs[:mid], new_batch_size))
760
+ failed_chunks.append((start_idx + mid, chunk_inputs[mid:], new_batch_size))
761
+ elif current_size > min_chunk_size:
762
+ logger.warning(
763
+ f"Embeddings request failed for single item at {start_idx} with size {current_size}. "
764
+ f"Splitting individual text content and retrying."
765
+ )
766
+ mid = len(chunk_inputs) // 2
767
+ failed_chunks.append((start_idx, chunk_inputs[:mid], 1))
768
+ failed_chunks.append((start_idx + mid, chunk_inputs[mid:], 1))
734
769
  else:
735
- # can't split further, re-raise the error
736
- logger.error(f"Failed to get embeddings for chunk starting at {start_idx} even with minimum size {min_chunk_size}")
770
+ logger.error(
771
+ f"Failed to get embeddings for chunk starting at {start_idx} even with batch_size=1 "
772
+ f"and minimum chunk size {min_chunk_size}. Error: {result}"
773
+ )
737
774
  raise result
738
775
  else:
739
776
  embeddings = [r.embedding for r in result.data]
@@ -1,9 +1,10 @@
1
+ # Import constants from settings to avoid circular import
2
+ # (settings.py imports from this module indirectly through log.py)
3
+ # Import this here to avoid circular dependency at module level
1
4
  from letta.local_llm.llm_chat_completion_wrappers.chatml import ChatMLInnerMonologueWrapper
5
+ from letta.settings import DEFAULT_WRAPPER_NAME, INNER_THOUGHTS_KWARG
2
6
 
3
7
  DEFAULT_WRAPPER = ChatMLInnerMonologueWrapper
4
- DEFAULT_WRAPPER_NAME = "chatml"
5
-
6
- INNER_THOUGHTS_KWARG = "thinking"
7
8
  INNER_THOUGHTS_KWARG_VERTEX = "thinking"
8
9
  VALID_INNER_THOUGHTS_KWARGS = ("thinking", "inner_thoughts")
9
10
  INNER_THOUGHTS_KWARG_DESCRIPTION = "Deep inner monologue private to you only."
@@ -1,6 +1,9 @@
1
1
  import json
2
2
  import re
3
- import warnings
3
+
4
+ from letta.log import get_logger
5
+
6
+ logger = get_logger(__name__)
4
7
 
5
8
  from letta.errors import LLMJSONParsingError
6
9
  from letta.helpers.json_helpers import json_loads
@@ -83,7 +86,7 @@ def clean_and_interpret_send_message_json(json_string):
83
86
 
84
87
  kwarg = model_settings.inner_thoughts_kwarg
85
88
  if kwarg not in VALID_INNER_THOUGHTS_KWARGS:
86
- warnings.warn(f"INNER_THOUGHTS_KWARG is not valid: {kwarg}")
89
+ logger.warning(f"INNER_THOUGHTS_KWARG is not valid: {kwarg}")
87
90
  kwarg = INNER_THOUGHTS_KWARG
88
91
 
89
92
  # If normal parsing fails, attempt to clean and extract manually
letta/local_llm/utils.py CHANGED
@@ -1,5 +1,4 @@
1
1
  import os
2
- import warnings
3
2
  from typing import List, Union
4
3
 
5
4
  import requests
@@ -84,11 +83,11 @@ def num_tokens_from_functions(functions: List[dict], model: str = "gpt-4"):
84
83
  function_tokens = len(encoding.encode(function["name"]))
85
84
  if function["description"]:
86
85
  if not isinstance(function["description"], str):
87
- warnings.warn(f"Function {function['name']} has non-string description: {function['description']}")
86
+ logger.warning(f"Function {function['name']} has non-string description: {function['description']}")
88
87
  else:
89
88
  function_tokens += len(encoding.encode(function["description"]))
90
89
  else:
91
- warnings.warn(f"Function {function['name']} has no description, function: {function}")
90
+ logger.warning(f"Function {function['name']} has no description, function: {function}")
92
91
 
93
92
  if "parameters" in function:
94
93
  parameters = function["parameters"]
letta/log.py CHANGED
@@ -1,14 +1,136 @@
1
+ import json
1
2
  import logging
3
+ import traceback
4
+ from datetime import datetime, timezone
2
5
  from logging.config import dictConfig
3
6
  from pathlib import Path
4
7
  from sys import stdout
5
- from typing import Optional
8
+ from typing import Any, Optional
6
9
 
7
- from letta.settings import settings
10
+ from letta.settings import log_settings, settings, telemetry_settings
8
11
 
9
12
  selected_log_level = logging.DEBUG if settings.debug else logging.INFO
10
13
 
11
14
 
15
+ class JSONFormatter(logging.Formatter):
16
+ """
17
+ Custom JSON formatter for structured logging with Datadog integration.
18
+
19
+ Outputs logs in JSON format with fields compatible with Datadog log ingestion.
20
+ Automatically includes trace correlation fields when Datadog tracing is enabled.
21
+
22
+ Usage:
23
+ Enable JSON logging by setting the environment variable:
24
+ LETTA_LOGGING_JSON_LOGGING=true
25
+
26
+ Add custom structured fields to logs using the 'extra' parameter:
27
+ logger.info("User action", extra={"user_id": "123", "action": "login"})
28
+
29
+ These fields will be automatically included in the JSON output and
30
+ indexed by Datadog for filtering and analysis.
31
+
32
+ Output format:
33
+ {
34
+ "timestamp": "2025-10-23T18:34:24.931739+00:00",
35
+ "level": "INFO",
36
+ "logger": "Letta.module",
37
+ "message": "Log message",
38
+ "module": "module_name",
39
+ "function": "function_name",
40
+ "line": 123,
41
+ "dd.trace_id": "1234567890", # Added when Datadog tracing is enabled
42
+ "dd.span_id": "9876543210", # Added when Datadog tracing is enabled
43
+ "custom_field": "custom_value" # Any extra fields you provide
44
+ }
45
+ """
46
+
47
+ def format(self, record: logging.LogRecord) -> str:
48
+ """Format log record as JSON with Datadog-compatible fields."""
49
+ # Base log structure
50
+ log_data: dict[str, Any] = {
51
+ "timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
52
+ "level": record.levelname,
53
+ "logger": record.name,
54
+ "message": record.getMessage(),
55
+ "module": record.module,
56
+ "function": record.funcName,
57
+ "line": record.lineno,
58
+ }
59
+
60
+ # Add Datadog trace correlation if available
61
+ # ddtrace automatically injects these attributes when logging is patched
62
+ if hasattr(record, "dd.trace_id"):
63
+ log_data["dd.trace_id"] = getattr(record, "dd.trace_id")
64
+ if hasattr(record, "dd.span_id"):
65
+ log_data["dd.span_id"] = getattr(record, "dd.span_id")
66
+ if hasattr(record, "dd.service"):
67
+ log_data["dd.service"] = getattr(record, "dd.service")
68
+ if hasattr(record, "dd.env"):
69
+ log_data["dd.env"] = getattr(record, "dd.env")
70
+ if hasattr(record, "dd.version"):
71
+ log_data["dd.version"] = getattr(record, "dd.version")
72
+
73
+ # Add exception info if present
74
+ if record.exc_info:
75
+ log_data["exception"] = {
76
+ "type": record.exc_info[0].__name__ if record.exc_info[0] else None,
77
+ "message": str(record.exc_info[1]) if record.exc_info[1] else None,
78
+ "stacktrace": "".join(traceback.format_exception(*record.exc_info)),
79
+ }
80
+
81
+ # Add any extra fields from the log record
82
+ # These are custom fields passed via logging.info("msg", extra={...})
83
+ for key, value in record.__dict__.items():
84
+ if key not in [
85
+ "name",
86
+ "msg",
87
+ "args",
88
+ "created",
89
+ "filename",
90
+ "funcName",
91
+ "levelname",
92
+ "levelno",
93
+ "lineno",
94
+ "module",
95
+ "msecs",
96
+ "message",
97
+ "pathname",
98
+ "process",
99
+ "processName",
100
+ "relativeCreated",
101
+ "thread",
102
+ "threadName",
103
+ "exc_info",
104
+ "exc_text",
105
+ "stack_info",
106
+ "dd_env",
107
+ "dd_service",
108
+ ] and not key.startswith("dd."):
109
+ log_data[key] = value
110
+
111
+ return json.dumps(log_data, default=str)
112
+
113
+
114
+ class DatadogEnvFilter(logging.Filter):
115
+ """
116
+ Logging filter that adds Datadog-specific attributes to log records.
117
+
118
+ This enables log-trace correlation by injecting environment and service metadata
119
+ that Datadog can use to link logs with traces and other telemetry data.
120
+ """
121
+
122
+ def filter(self, record: logging.LogRecord) -> bool:
123
+ """Add Datadog attributes to log record if Datadog is enabled."""
124
+ if telemetry_settings.enable_datadog:
125
+ record.dd_env = telemetry_settings.datadog_env
126
+ record.dd_service = "letta-server"
127
+ else:
128
+ # Provide defaults to prevent attribute errors if filter is applied incorrectly
129
+ record.dd_env = ""
130
+ record.dd_service = ""
131
+ return True
132
+
133
+
12
134
  def _setup_logfile() -> "Path":
13
135
  """ensure the logger filepath is in place
14
136
 
@@ -20,28 +142,65 @@ def _setup_logfile() -> "Path":
20
142
  return logfile
21
143
 
22
144
 
23
- # TODO: production logging should be much less invasive
145
+ # Determine which formatter to use based on configuration
146
+ def _get_console_formatter() -> str:
147
+ """Determine the appropriate console formatter based on settings."""
148
+ if log_settings.json_logging:
149
+ return "json"
150
+ elif telemetry_settings.enable_datadog:
151
+ return "datadog"
152
+ else:
153
+ return "no_datetime"
154
+
155
+
156
+ def _get_file_formatter() -> str:
157
+ """Determine the appropriate file formatter based on settings."""
158
+ if log_settings.json_logging:
159
+ return "json"
160
+ elif telemetry_settings.enable_datadog:
161
+ return "datadog"
162
+ else:
163
+ return "standard"
164
+
165
+
166
+ # Logging configuration with optional Datadog integration and JSON support
24
167
  DEVELOPMENT_LOGGING = {
25
168
  "version": 1,
26
169
  "disable_existing_loggers": False, # Allow capturing from all loggers
27
170
  "formatters": {
28
171
  "standard": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"},
29
172
  "no_datetime": {"format": "%(name)s - %(levelname)s - %(message)s"},
173
+ "datadog": {
174
+ # Datadog-compatible format with key=value pairs for better parsing
175
+ # ddtrace's log injection will add dd.trace_id, dd.span_id automatically when logging is patched
176
+ "format": "%(asctime)s - %(name)s - %(levelname)s - [dd.env=%(dd_env)s dd.service=%(dd_service)s] - %(message)s"
177
+ },
178
+ "json": {
179
+ # JSON formatter for structured logging with full Datadog integration
180
+ "()": JSONFormatter,
181
+ },
182
+ },
183
+ "filters": {
184
+ "datadog_env": {
185
+ "()": DatadogEnvFilter,
186
+ },
30
187
  },
31
188
  "handlers": {
32
189
  "console": {
33
190
  "level": selected_log_level,
34
191
  "class": "logging.StreamHandler",
35
192
  "stream": stdout,
36
- "formatter": "no_datetime",
193
+ "formatter": _get_console_formatter(),
194
+ "filters": ["datadog_env"] if telemetry_settings.enable_datadog and not log_settings.json_logging else [],
37
195
  },
38
196
  "file": {
39
197
  "level": "DEBUG",
40
198
  "class": "logging.handlers.RotatingFileHandler",
41
199
  "filename": _setup_logfile(),
42
- "maxBytes": 1024**2 * 10,
43
- "backupCount": 3,
44
- "formatter": "standard",
200
+ "maxBytes": 1024**2 * 10, # 10 MB per file
201
+ "backupCount": 3, # Keep 3 backup files
202
+ "formatter": _get_file_formatter(),
203
+ "filters": ["datadog_env"] if telemetry_settings.enable_datadog and not log_settings.json_logging else [],
45
204
  },
46
205
  },
47
206
  "root": { # Root logger handles all logs
@@ -58,6 +217,11 @@ DEVELOPMENT_LOGGING = {
58
217
  "handlers": ["console"],
59
218
  "propagate": True,
60
219
  },
220
+ # Reduce noise from ddtrace internal logging
221
+ "ddtrace": {
222
+ "level": "WARNING",
223
+ "propagate": True,
224
+ },
61
225
  },
62
226
  }
63
227
 
letta/orm/agent.py CHANGED
@@ -241,7 +241,9 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
241
241
  "tools": [],
242
242
  "sources": [],
243
243
  "memory": Memory(blocks=[]),
244
+ "blocks": [],
244
245
  "identity_ids": [],
246
+ "identities": [],
245
247
  "multi_agent_group": None,
246
248
  "tool_exec_environment_variables": [],
247
249
  "secrets": [],
@@ -262,8 +264,11 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
262
264
  ],
263
265
  agent_type=self.agent_type,
264
266
  ),
267
+ "blocks": lambda: [b.to_pydantic() for b in self.core_memory],
265
268
  "identity_ids": lambda: [i.id for i in self.identities],
269
+ "identities": lambda: [i.to_pydantic() for i in self.identities], # TODO: fix this
266
270
  "multi_agent_group": lambda: self.multi_agent_group,
271
+ "managed_group": lambda: self.multi_agent_group,
267
272
  "tool_exec_environment_variables": lambda: self.tool_exec_environment_variables,
268
273
  "secrets": lambda: self.tool_exec_environment_variables,
269
274
  }
@@ -277,7 +282,11 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
277
282
 
278
283
  return self.__pydantic_model__(**state)
279
284
 
280
- async def to_pydantic_async(self, include_relationships: Optional[Set[str]] = None) -> PydanticAgentState:
285
+ async def to_pydantic_async(
286
+ self,
287
+ include_relationships: Optional[Set[str]] = None,
288
+ include: Optional[List[str]] = None,
289
+ ) -> PydanticAgentState:
281
290
  """
282
291
  Converts the SQLAlchemy Agent model into its Pydantic counterpart.
283
292
 
@@ -334,8 +343,11 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
334
343
  "tools": [],
335
344
  "sources": [],
336
345
  "memory": Memory(blocks=[]),
346
+ "blocks": [],
337
347
  "identity_ids": [],
348
+ "identities": [],
338
349
  "multi_agent_group": None,
350
+ "managed_group": None,
339
351
  "tool_exec_environment_variables": [],
340
352
  "secrets": [],
341
353
  }
@@ -343,6 +355,9 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
343
355
  # Initialize include_relationships to an empty set if it's None
344
356
  include_relationships = set(optional_fields.keys() if include_relationships is None else include_relationships)
345
357
 
358
+ # Convert include list to set for efficient membership checks
359
+ include_set = set(include) if include else set()
360
+
346
361
  async def empty_list_async():
347
362
  return []
348
363
 
@@ -350,18 +365,34 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
350
365
  return None
351
366
 
352
367
  # Only load requested relationships
353
- tags = self.awaitable_attrs.tags if "tags" in include_relationships else empty_list_async()
354
- tools = self.awaitable_attrs.tools if "tools" in include_relationships else empty_list_async()
355
- sources = self.awaitable_attrs.sources if "sources" in include_relationships else empty_list_async()
356
- memory = self.awaitable_attrs.core_memory if "memory" in include_relationships else empty_list_async()
357
- identities = self.awaitable_attrs.identities if "identity_ids" in include_relationships else empty_list_async()
358
- multi_agent_group = self.awaitable_attrs.multi_agent_group if "multi_agent_group" in include_relationships else none_async()
368
+ tags = self.awaitable_attrs.tags if "tags" in include_relationships or "agent.tags" in include_set else empty_list_async()
369
+ tools = self.awaitable_attrs.tools if "tools" in include_relationships or "agent.tools" in include_set else empty_list_async()
370
+ sources = (
371
+ self.awaitable_attrs.sources if "sources" in include_relationships or "agent.sources" in include_set else empty_list_async()
372
+ )
373
+ memory = (
374
+ self.awaitable_attrs.core_memory if "memory" in include_relationships or "agent.blocks" in include_set else empty_list_async()
375
+ )
376
+ identities = (
377
+ self.awaitable_attrs.identities
378
+ if "identity_ids" in include_relationships or "agent.identities" in include_set
379
+ else empty_list_async()
380
+ )
381
+ multi_agent_group = (
382
+ self.awaitable_attrs.multi_agent_group
383
+ if "multi_agent_group" in include_relationships or "agent.managed_group" in include_set
384
+ else none_async()
385
+ )
359
386
  tool_exec_environment_variables = (
360
387
  self.awaitable_attrs.tool_exec_environment_variables
361
- if "tool_exec_environment_variables" in include_relationships or "secrets" in include_relationships
388
+ if "tool_exec_environment_variables" in include_relationships
389
+ or "secrets" in include_relationships
390
+ or "agent.secrets" in include_set
362
391
  else empty_list_async()
363
392
  )
364
- file_agents = self.awaitable_attrs.file_agents if "memory" in include_relationships else empty_list_async()
393
+ file_agents = (
394
+ self.awaitable_attrs.file_agents if "memory" in include_relationships or "agent.blocks" in include_set else empty_list_async()
395
+ )
365
396
 
366
397
  (tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents) = await asyncio.gather(
367
398
  tags, tools, sources, memory, identities, multi_agent_group, tool_exec_environment_variables, file_agents
@@ -379,8 +410,11 @@ class Agent(SqlalchemyBase, OrganizationMixin, ProjectMixin, TemplateEntityMixin
379
410
  ],
380
411
  agent_type=self.agent_type,
381
412
  )
413
+ state["blocks"] = [m.to_pydantic() for m in memory]
382
414
  state["identity_ids"] = [i.id for i in identities]
415
+ state["identities"] = [i.to_pydantic() for i in identities]
383
416
  state["multi_agent_group"] = multi_agent_group
417
+ state["managed_group"] = multi_agent_group
384
418
  state["tool_exec_environment_variables"] = tool_exec_environment_variables
385
419
  state["secrets"] = tool_exec_environment_variables
386
420
 
letta/orm/archive.py CHANGED
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, List, Optional
5
5
  from sqlalchemy import JSON, Enum, Index, String
6
6
  from sqlalchemy.orm import Mapped, mapped_column, relationship
7
7
 
8
+ from letta.orm.custom_columns import EmbeddingConfigColumn
8
9
  from letta.orm.mixins import OrganizationMixin
9
10
  from letta.orm.sqlalchemy_base import SqlalchemyBase
10
11
  from letta.schemas.archive import Archive as PydanticArchive
@@ -45,6 +46,9 @@ class Archive(SqlalchemyBase, OrganizationMixin):
45
46
  default=VectorDBProvider.NATIVE,
46
47
  doc="The vector database provider used for this archive's passages",
47
48
  )
49
+ embedding_config: Mapped[dict] = mapped_column(
50
+ EmbeddingConfigColumn, nullable=False, doc="Embedding configuration for passages in this archive"
51
+ )
48
52
  metadata_: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, doc="Additional metadata for the archive")
49
53
  _vector_db_namespace: Mapped[Optional[str]] = mapped_column(String, nullable=True, doc="Private field for vector database namespace")
50
54
 
@@ -3,6 +3,7 @@ from sqlalchemy.types import BINARY, TypeDecorator
3
3
 
4
4
  from letta.helpers.converters import (
5
5
  deserialize_agent_step_state,
6
+ deserialize_approvals,
6
7
  deserialize_batch_request_result,
7
8
  deserialize_create_batch_response,
8
9
  deserialize_embedding_config,
@@ -16,6 +17,7 @@ from letta.helpers.converters import (
16
17
  deserialize_tool_rules,
17
18
  deserialize_vector,
18
19
  serialize_agent_step_state,
20
+ serialize_approvals,
19
21
  serialize_batch_request_result,
20
22
  serialize_create_batch_response,
21
23
  serialize_embedding_config,
@@ -96,6 +98,19 @@ class ToolReturnColumn(TypeDecorator):
96
98
  return deserialize_tool_returns(value)
97
99
 
98
100
 
101
+ class ApprovalsColumn(TypeDecorator):
102
+ """Custom SQLAlchemy column type for storing the approval responses of a tool call request as JSON."""
103
+
104
+ impl = JSON
105
+ cache_ok = True
106
+
107
+ def process_bind_param(self, value, dialect):
108
+ return serialize_approvals(value)
109
+
110
+ def process_result_value(self, value, dialect):
111
+ return deserialize_approvals(value)
112
+
113
+
99
114
  class MessageContentColumn(TypeDecorator):
100
115
  """Custom SQLAlchemy column type for storing the content parts of a message as JSON."""
101
116