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.
- letta/__init__.py +2 -3
- letta/adapters/letta_llm_adapter.py +1 -0
- letta/adapters/simple_llm_request_adapter.py +8 -5
- letta/adapters/simple_llm_stream_adapter.py +22 -6
- letta/agents/agent_loop.py +10 -3
- letta/agents/base_agent.py +4 -1
- letta/agents/helpers.py +41 -9
- letta/agents/letta_agent.py +11 -10
- letta/agents/letta_agent_v2.py +47 -37
- letta/agents/letta_agent_v3.py +395 -300
- letta/agents/voice_agent.py +8 -6
- letta/agents/voice_sleeptime_agent.py +3 -3
- letta/constants.py +30 -7
- letta/errors.py +20 -0
- letta/functions/function_sets/base.py +55 -3
- letta/functions/mcp_client/types.py +33 -57
- letta/functions/schema_generator.py +135 -23
- letta/groups/sleeptime_multi_agent_v3.py +6 -11
- letta/groups/sleeptime_multi_agent_v4.py +227 -0
- letta/helpers/converters.py +78 -4
- letta/helpers/crypto_utils.py +6 -2
- letta/interfaces/anthropic_parallel_tool_call_streaming_interface.py +9 -11
- letta/interfaces/anthropic_streaming_interface.py +3 -4
- letta/interfaces/gemini_streaming_interface.py +4 -6
- letta/interfaces/openai_streaming_interface.py +63 -28
- letta/llm_api/anthropic_client.py +7 -4
- letta/llm_api/deepseek_client.py +6 -4
- letta/llm_api/google_ai_client.py +3 -12
- letta/llm_api/google_vertex_client.py +1 -1
- letta/llm_api/helpers.py +90 -61
- letta/llm_api/llm_api_tools.py +4 -1
- letta/llm_api/openai.py +12 -12
- letta/llm_api/openai_client.py +53 -16
- letta/local_llm/constants.py +4 -3
- letta/local_llm/json_parser.py +5 -2
- letta/local_llm/utils.py +2 -3
- letta/log.py +171 -7
- letta/orm/agent.py +43 -9
- letta/orm/archive.py +4 -0
- letta/orm/custom_columns.py +15 -0
- letta/orm/identity.py +11 -11
- letta/orm/mcp_server.py +9 -0
- letta/orm/message.py +6 -1
- letta/orm/run_metrics.py +7 -2
- letta/orm/sqlalchemy_base.py +2 -2
- letta/orm/tool.py +3 -0
- letta/otel/tracing.py +2 -0
- letta/prompts/prompt_generator.py +7 -2
- letta/schemas/agent.py +41 -10
- letta/schemas/agent_file.py +3 -0
- letta/schemas/archive.py +4 -2
- letta/schemas/block.py +2 -1
- letta/schemas/enums.py +36 -3
- letta/schemas/file.py +3 -3
- letta/schemas/folder.py +2 -1
- letta/schemas/group.py +2 -1
- letta/schemas/identity.py +18 -9
- letta/schemas/job.py +3 -1
- letta/schemas/letta_message.py +71 -12
- letta/schemas/letta_request.py +7 -3
- letta/schemas/letta_stop_reason.py +0 -25
- letta/schemas/llm_config.py +8 -2
- letta/schemas/mcp.py +80 -83
- letta/schemas/mcp_server.py +349 -0
- letta/schemas/memory.py +20 -8
- letta/schemas/message.py +212 -67
- letta/schemas/providers/anthropic.py +13 -6
- letta/schemas/providers/azure.py +6 -4
- letta/schemas/providers/base.py +8 -4
- letta/schemas/providers/bedrock.py +6 -2
- letta/schemas/providers/cerebras.py +7 -3
- letta/schemas/providers/deepseek.py +2 -1
- letta/schemas/providers/google_gemini.py +15 -6
- letta/schemas/providers/groq.py +2 -1
- letta/schemas/providers/lmstudio.py +9 -6
- letta/schemas/providers/mistral.py +2 -1
- letta/schemas/providers/openai.py +7 -2
- letta/schemas/providers/together.py +9 -3
- letta/schemas/providers/xai.py +7 -3
- letta/schemas/run.py +7 -2
- letta/schemas/run_metrics.py +2 -1
- letta/schemas/sandbox_config.py +2 -2
- letta/schemas/secret.py +3 -158
- letta/schemas/source.py +2 -2
- letta/schemas/step.py +2 -2
- letta/schemas/tool.py +24 -1
- letta/schemas/usage.py +0 -1
- letta/server/rest_api/app.py +123 -7
- letta/server/rest_api/dependencies.py +3 -0
- letta/server/rest_api/interface.py +7 -4
- letta/server/rest_api/redis_stream_manager.py +16 -1
- letta/server/rest_api/routers/v1/__init__.py +7 -0
- letta/server/rest_api/routers/v1/agents.py +332 -322
- letta/server/rest_api/routers/v1/archives.py +127 -40
- letta/server/rest_api/routers/v1/blocks.py +54 -6
- letta/server/rest_api/routers/v1/chat_completions.py +146 -0
- letta/server/rest_api/routers/v1/folders.py +27 -35
- letta/server/rest_api/routers/v1/groups.py +23 -35
- letta/server/rest_api/routers/v1/identities.py +24 -10
- letta/server/rest_api/routers/v1/internal_runs.py +107 -0
- letta/server/rest_api/routers/v1/internal_templates.py +162 -179
- letta/server/rest_api/routers/v1/jobs.py +15 -27
- letta/server/rest_api/routers/v1/mcp_servers.py +309 -0
- letta/server/rest_api/routers/v1/messages.py +23 -34
- letta/server/rest_api/routers/v1/organizations.py +6 -27
- letta/server/rest_api/routers/v1/providers.py +35 -62
- letta/server/rest_api/routers/v1/runs.py +30 -43
- letta/server/rest_api/routers/v1/sandbox_configs.py +6 -4
- letta/server/rest_api/routers/v1/sources.py +26 -42
- letta/server/rest_api/routers/v1/steps.py +16 -29
- letta/server/rest_api/routers/v1/tools.py +17 -13
- letta/server/rest_api/routers/v1/users.py +5 -17
- letta/server/rest_api/routers/v1/voice.py +18 -27
- letta/server/rest_api/streaming_response.py +5 -2
- letta/server/rest_api/utils.py +187 -25
- letta/server/server.py +27 -22
- letta/server/ws_api/server.py +5 -4
- letta/services/agent_manager.py +148 -26
- letta/services/agent_serialization_manager.py +6 -1
- letta/services/archive_manager.py +168 -15
- letta/services/block_manager.py +14 -4
- letta/services/file_manager.py +33 -29
- letta/services/group_manager.py +10 -0
- letta/services/helpers/agent_manager_helper.py +65 -11
- letta/services/identity_manager.py +105 -4
- letta/services/job_manager.py +11 -1
- letta/services/mcp/base_client.py +2 -2
- letta/services/mcp/oauth_utils.py +33 -8
- letta/services/mcp_manager.py +174 -78
- letta/services/mcp_server_manager.py +1331 -0
- letta/services/message_manager.py +109 -4
- letta/services/organization_manager.py +4 -4
- letta/services/passage_manager.py +9 -25
- letta/services/provider_manager.py +91 -15
- letta/services/run_manager.py +72 -15
- letta/services/sandbox_config_manager.py +45 -3
- letta/services/source_manager.py +15 -8
- letta/services/step_manager.py +24 -1
- letta/services/streaming_service.py +581 -0
- letta/services/summarizer/summarizer.py +1 -1
- letta/services/tool_executor/core_tool_executor.py +111 -0
- letta/services/tool_executor/files_tool_executor.py +5 -3
- letta/services/tool_executor/sandbox_tool_executor.py +2 -2
- letta/services/tool_executor/tool_execution_manager.py +1 -1
- letta/services/tool_manager.py +10 -3
- letta/services/tool_sandbox/base.py +61 -1
- letta/services/tool_sandbox/local_sandbox.py +1 -3
- letta/services/user_manager.py +2 -2
- letta/settings.py +49 -5
- letta/system.py +14 -5
- letta/utils.py +73 -1
- letta/validators.py +105 -0
- {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/METADATA +4 -2
- {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/RECORD +157 -151
- letta/schemas/letta_ping.py +0 -28
- letta/server/rest_api/routers/openai/chat_completions/__init__.py +0 -0
- {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/WHEEL +0 -0
- {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/entry_points.txt +0 -0
- {letta_nightly-0.12.1.dev20251023104211.dist-info → letta_nightly-0.13.0.dev20251024223017.dist-info}/licenses/LICENSE +0 -0
letta/llm_api/llm_api_tools.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
letta/llm_api/openai_client.py
CHANGED
|
@@ -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
|
-
|
|
710
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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]
|
letta/local_llm/constants.py
CHANGED
|
@@ -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."
|
letta/local_llm/json_parser.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import re
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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":
|
|
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":
|
|
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(
|
|
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 =
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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 =
|
|
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
|
|
letta/orm/custom_columns.py
CHANGED
|
@@ -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
|
|